From d6653fc059731e2b098452925e52efebf48f1585 Mon Sep 17 00:00:00 2001 From: cdrappier Date: Wed, 24 Jun 2026 20:06:32 +0000 Subject: [PATCH 1/5] feat: regenerate controlplane client with DrivePermission types (ENG-2761) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../core/client/api/applications/__init__.py | 0 .../api/applications/create_application.py | 199 +++++++++++++ .../api/applications/delete_application.py | 179 ++++++++++++ .../api/applications/get_application.py | 179 ++++++++++++ .../list_application_revisions.py | 153 ++++++++++ .../api/applications/list_applications.py | 261 ++++++++++++++++++ .../api/applications/update_application.py | 212 ++++++++++++++ .../create_application_custom_domain.py | 195 +++++++++++++ src/blaxel/core/client/client.py | 5 +- src/blaxel/core/client/models/__init__.py | 28 +- src/blaxel/core/client/models/app_revision.py | 139 ++++++++++ .../models/app_revision_configuration.py | 97 +++++++ src/blaxel/core/client/models/app_url.py | 76 +++++ src/blaxel/core/client/models/application.py | 126 +++++++++ .../core/client/models/application_list.py | 105 +++++++ .../core/client/models/application_spec.py | 164 +++++++++++ .../core/client/models/custom_domain_spec.py | 52 ++-- ...ain_spec_subject_alternative_names_item.py | 45 +++ .../client/models/custom_domain_subdomain.py | 97 ------- .../core/client/models/drive_permission.py | 105 +++++++ .../client/models/drive_permission_labels.py | 45 +++ .../client/models/drive_permission_mode.py | 18 ++ src/blaxel/core/client/models/drive_spec.py | 32 ++- .../core/client/models/function_runtime.py | 24 ++ .../client/models/list_applications_anchor.py | 17 ++ .../client/models/list_applications_sort.py | 20 ++ src/blaxel/core/client/models/owner_fields.py | 2 +- .../client/models/policy_resource_type.py | 1 + .../core/client/models/sandbox_definition.py | 6 +- src/blaxel/core/client/models/time_fields.py | 2 +- src/blaxel/core/client/models/workspace.py | 26 +- tests/core/test_logger.py | 8 +- .../core/sandbox/proxy/test_claude.py | 112 ++++---- .../core/sandbox/proxy/test_cli_tools.py | 253 ++++++++++------- .../core/sandbox/proxy/test_create.py | 190 +++++++------ .../core/sandbox/proxy/test_e2e.py | 170 +++++++----- .../core/sandbox/proxy/test_firewall.py | 222 ++++++++------- .../core/sandbox/proxy/test_get_delete.py | 88 +++--- .../core/sandbox/proxy/test_python.py | 86 +++--- .../core/sandbox/proxy/test_secrets.py | 162 ++++++----- .../core/sandbox/proxy/test_wildcard.py | 44 +-- tests/integration/livekit/test_model.py | 4 +- 42 files changed, 3217 insertions(+), 732 deletions(-) create mode 100644 src/blaxel/core/client/api/applications/__init__.py create mode 100644 src/blaxel/core/client/api/applications/create_application.py create mode 100644 src/blaxel/core/client/api/applications/delete_application.py create mode 100644 src/blaxel/core/client/api/applications/get_application.py create mode 100644 src/blaxel/core/client/api/applications/list_application_revisions.py create mode 100644 src/blaxel/core/client/api/applications/list_applications.py create mode 100644 src/blaxel/core/client/api/applications/update_application.py create mode 100644 src/blaxel/core/client/api/customdomains/create_application_custom_domain.py create mode 100644 src/blaxel/core/client/models/app_revision.py create mode 100644 src/blaxel/core/client/models/app_revision_configuration.py create mode 100644 src/blaxel/core/client/models/app_url.py create mode 100644 src/blaxel/core/client/models/application.py create mode 100644 src/blaxel/core/client/models/application_list.py create mode 100644 src/blaxel/core/client/models/application_spec.py create mode 100644 src/blaxel/core/client/models/custom_domain_spec_subject_alternative_names_item.py delete mode 100644 src/blaxel/core/client/models/custom_domain_subdomain.py create mode 100644 src/blaxel/core/client/models/drive_permission.py create mode 100644 src/blaxel/core/client/models/drive_permission_labels.py create mode 100644 src/blaxel/core/client/models/drive_permission_mode.py create mode 100644 src/blaxel/core/client/models/list_applications_anchor.py create mode 100644 src/blaxel/core/client/models/list_applications_sort.py diff --git a/src/blaxel/core/client/api/applications/__init__.py b/src/blaxel/core/client/api/applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blaxel/core/client/api/applications/create_application.py b/src/blaxel/core/client/api/applications/create_application.py new file mode 100644 index 00000000..765d4441 --- /dev/null +++ b/src/blaxel/core/client/api/applications/create_application.py @@ -0,0 +1,199 @@ +from http import HTTPStatus +from typing import Any, Union + +import httpx + +from ... import errors +from ...client import Client +from ...models.application import Application +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + *, + body: Application, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/applications", + } + + if type(body) is dict: + _body = body + else: + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Client, response: httpx.Response +) -> Union[Application, Error] | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[Union[Application, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: Client, + body: Application, +) -> Response[Union[Application, Error]]: + """Create application + + Creates a new application deployment. Applications are long-running workloads that default to public + access and mk3 generation. + + Args: + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: Client, + body: Application, +) -> Union[Application, Error] | None: + """Create application + + Creates a new application deployment. Applications are long-running workloads that default to public + access and mk3 generation. + + Args: + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: Client, + body: Application, +) -> Response[Union[Application, Error]]: + """Create application + + Creates a new application deployment. Applications are long-running workloads that default to public + access and mk3 generation. + + Args: + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: Client, + body: Application, +) -> Union[Application, Error] | None: + """Create application + + Creates a new application deployment. Applications are long-running workloads that default to public + access and mk3 generation. + + Args: + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/src/blaxel/core/client/api/applications/delete_application.py b/src/blaxel/core/client/api/applications/delete_application.py new file mode 100644 index 00000000..c21164cf --- /dev/null +++ b/src/blaxel/core/client/api/applications/delete_application.py @@ -0,0 +1,179 @@ +from http import HTTPStatus +from typing import Any, Union + +import httpx + +from ... import errors +from ...client import Client +from ...models.application import Application +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + application_name: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/applications/{application_name}", + } + + return _kwargs + + +def _parse_response( + *, client: Client, response: httpx.Response +) -> Union[Application, Error] | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[Union[Application, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_name: str, + *, + client: Client, +) -> Response[Union[Application, Error]]: + """Delete application + + Permanently deletes an application and all its deployment history. The application endpoint will + immediately stop responding. This action cannot be undone. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_name: str, + *, + client: Client, +) -> Union[Application, Error] | None: + """Delete application + + Permanently deletes an application and all its deployment history. The application endpoint will + immediately stop responding. This action cannot be undone. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return sync_detailed( + application_name=application_name, + client=client, + ).parsed + + +async def asyncio_detailed( + application_name: str, + *, + client: Client, +) -> Response[Union[Application, Error]]: + """Delete application + + Permanently deletes an application and all its deployment history. The application endpoint will + immediately stop responding. This action cannot be undone. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_name: str, + *, + client: Client, +) -> Union[Application, Error] | None: + """Delete application + + Permanently deletes an application and all its deployment history. The application endpoint will + immediately stop responding. This action cannot be undone. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return ( + await asyncio_detailed( + application_name=application_name, + client=client, + ) + ).parsed diff --git a/src/blaxel/core/client/api/applications/get_application.py b/src/blaxel/core/client/api/applications/get_application.py new file mode 100644 index 00000000..ada84c8d --- /dev/null +++ b/src/blaxel/core/client/api/applications/get_application.py @@ -0,0 +1,179 @@ +from http import HTTPStatus +from typing import Any, Union + +import httpx + +from ... import errors +from ...client import Client +from ...models.application import Application +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + application_name: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/applications/{application_name}", + } + + return _kwargs + + +def _parse_response( + *, client: Client, response: httpx.Response +) -> Union[Application, Error] | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[Union[Application, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_name: str, + *, + client: Client, +) -> Response[Union[Application, Error]]: + """Get application + + Returns detailed information about an application including its current deployment status, + configuration, events history, and endpoint URL. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_name: str, + *, + client: Client, +) -> Union[Application, Error] | None: + """Get application + + Returns detailed information about an application including its current deployment status, + configuration, events history, and endpoint URL. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return sync_detailed( + application_name=application_name, + client=client, + ).parsed + + +async def asyncio_detailed( + application_name: str, + *, + client: Client, +) -> Response[Union[Application, Error]]: + """Get application + + Returns detailed information about an application including its current deployment status, + configuration, events history, and endpoint URL. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_name: str, + *, + client: Client, +) -> Union[Application, Error] | None: + """Get application + + Returns detailed information about an application including its current deployment status, + configuration, events history, and endpoint URL. + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return ( + await asyncio_detailed( + application_name=application_name, + client=client, + ) + ).parsed diff --git a/src/blaxel/core/client/api/applications/list_application_revisions.py b/src/blaxel/core/client/api/applications/list_application_revisions.py new file mode 100644 index 00000000..0bc6c201 --- /dev/null +++ b/src/blaxel/core/client/api/applications/list_application_revisions.py @@ -0,0 +1,153 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import Client +from ...models.revision_metadata import RevisionMetadata +from ...types import Response + + +def _get_kwargs( + application_name: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/applications/{application_name}/revisions", + } + + return _kwargs + + +def _parse_response(*, client: Client, response: httpx.Response) -> list["RevisionMetadata"] | None: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = RevisionMetadata.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[list["RevisionMetadata"]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_name: str, + *, + client: Client, +) -> Response[list["RevisionMetadata"]]: + """List all application revisions + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list['RevisionMetadata']] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_name: str, + *, + client: Client, +) -> list["RevisionMetadata"] | None: + """List all application revisions + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list['RevisionMetadata'] + """ + + return sync_detailed( + application_name=application_name, + client=client, + ).parsed + + +async def asyncio_detailed( + application_name: str, + *, + client: Client, +) -> Response[list["RevisionMetadata"]]: + """List all application revisions + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list['RevisionMetadata']] + """ + + kwargs = _get_kwargs( + application_name=application_name, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_name: str, + *, + client: Client, +) -> list["RevisionMetadata"] | None: + """List all application revisions + + Args: + application_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list['RevisionMetadata'] + """ + + return ( + await asyncio_detailed( + application_name=application_name, + client=client, + ) + ).parsed diff --git a/src/blaxel/core/client/api/applications/list_applications.py b/src/blaxel/core/client/api/applications/list_applications.py new file mode 100644 index 00000000..66195847 --- /dev/null +++ b/src/blaxel/core/client/api/applications/list_applications.py @@ -0,0 +1,261 @@ +from http import HTTPStatus +from typing import Any, Union + +import httpx + +from ... import errors +from ...client import Client +from ...models.application_list import ApplicationList +from ...models.error import Error +from ...models.list_applications_anchor import ListApplicationsAnchor +from ...models.list_applications_sort import ListApplicationsSort +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + cursor: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 50, + sort: Union[Unset, ListApplicationsSort] = UNSET, + q: Union[Unset, str] = UNSET, + anchor: Union[Unset, ListApplicationsAnchor] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["limit"] = limit + + json_sort: Union[Unset, str] = UNSET + if not isinstance(sort, Unset): + json_sort = sort.value + + params["sort"] = json_sort + + params["q"] = q + + json_anchor: Union[Unset, str] = UNSET + if not isinstance(anchor, Unset): + json_anchor = anchor.value + + params["anchor"] = json_anchor + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/applications", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Client, response: httpx.Response +) -> Union[ApplicationList, Error] | None: + if response.status_code == 200: + response_200 = ApplicationList.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[Union[ApplicationList, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: Client, + cursor: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 50, + sort: Union[Unset, ListApplicationsSort] = UNSET, + q: Union[Unset, str] = UNSET, + anchor: Union[Unset, ListApplicationsAnchor] = UNSET, +) -> Response[Union[ApplicationList, Error]]: + """List all applications + + Returns applications deployed in the workspace. Each application includes its deployment status, + runtime configuration, and endpoint URL. Starting with API version 2026-04-28 the response is + wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query + parameters; older versions keep returning a bare array with all applications. + + Args: + cursor (Union[Unset, str]): + limit (Union[Unset, int]): Default: 50. + sort (Union[Unset, ListApplicationsSort]): + q (Union[Unset, str]): + anchor (Union[Unset, ListApplicationsAnchor]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[ApplicationList, Error]] + """ + + kwargs = _get_kwargs( + cursor=cursor, + limit=limit, + sort=sort, + q=q, + anchor=anchor, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: Client, + cursor: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 50, + sort: Union[Unset, ListApplicationsSort] = UNSET, + q: Union[Unset, str] = UNSET, + anchor: Union[Unset, ListApplicationsAnchor] = UNSET, +) -> Union[ApplicationList, Error] | None: + """List all applications + + Returns applications deployed in the workspace. Each application includes its deployment status, + runtime configuration, and endpoint URL. Starting with API version 2026-04-28 the response is + wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query + parameters; older versions keep returning a bare array with all applications. + + Args: + cursor (Union[Unset, str]): + limit (Union[Unset, int]): Default: 50. + sort (Union[Unset, ListApplicationsSort]): + q (Union[Unset, str]): + anchor (Union[Unset, ListApplicationsAnchor]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[ApplicationList, Error] + """ + + return sync_detailed( + client=client, + cursor=cursor, + limit=limit, + sort=sort, + q=q, + anchor=anchor, + ).parsed + + +async def asyncio_detailed( + *, + client: Client, + cursor: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 50, + sort: Union[Unset, ListApplicationsSort] = UNSET, + q: Union[Unset, str] = UNSET, + anchor: Union[Unset, ListApplicationsAnchor] = UNSET, +) -> Response[Union[ApplicationList, Error]]: + """List all applications + + Returns applications deployed in the workspace. Each application includes its deployment status, + runtime configuration, and endpoint URL. Starting with API version 2026-04-28 the response is + wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query + parameters; older versions keep returning a bare array with all applications. + + Args: + cursor (Union[Unset, str]): + limit (Union[Unset, int]): Default: 50. + sort (Union[Unset, ListApplicationsSort]): + q (Union[Unset, str]): + anchor (Union[Unset, ListApplicationsAnchor]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[ApplicationList, Error]] + """ + + kwargs = _get_kwargs( + cursor=cursor, + limit=limit, + sort=sort, + q=q, + anchor=anchor, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: Client, + cursor: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 50, + sort: Union[Unset, ListApplicationsSort] = UNSET, + q: Union[Unset, str] = UNSET, + anchor: Union[Unset, ListApplicationsAnchor] = UNSET, +) -> Union[ApplicationList, Error] | None: + """List all applications + + Returns applications deployed in the workspace. Each application includes its deployment status, + runtime configuration, and endpoint URL. Starting with API version 2026-04-28 the response is + wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query + parameters; older versions keep returning a bare array with all applications. + + Args: + cursor (Union[Unset, str]): + limit (Union[Unset, int]): Default: 50. + sort (Union[Unset, ListApplicationsSort]): + q (Union[Unset, str]): + anchor (Union[Unset, ListApplicationsAnchor]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[ApplicationList, Error] + """ + + return ( + await asyncio_detailed( + client=client, + cursor=cursor, + limit=limit, + sort=sort, + q=q, + anchor=anchor, + ) + ).parsed diff --git a/src/blaxel/core/client/api/applications/update_application.py b/src/blaxel/core/client/api/applications/update_application.py new file mode 100644 index 00000000..b81e55f1 --- /dev/null +++ b/src/blaxel/core/client/api/applications/update_application.py @@ -0,0 +1,212 @@ +from http import HTTPStatus +from typing import Any, Union + +import httpx + +from ... import errors +from ...client import Client +from ...models.application import Application +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + application_name: str, + *, + body: Application, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": f"/applications/{application_name}", + } + + if type(body) is dict: + _body = body + else: + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Client, response: httpx.Response +) -> Union[Application, Error] | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Client, response: httpx.Response +) -> Response[Union[Application, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_name: str, + *, + client: Client, + body: Application, +) -> Response[Union[Application, Error]]: + """Update application + + Updates an application's configuration and triggers a new deployment. Changes to runtime settings, + environment variables, or scaling parameters will be applied on the next deployment. + + Args: + application_name (str): + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_name: str, + *, + client: Client, + body: Application, +) -> Union[Application, Error] | None: + """Update application + + Updates an application's configuration and triggers a new deployment. Changes to runtime settings, + environment variables, or scaling parameters will be applied on the next deployment. + + Args: + application_name (str): + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return sync_detailed( + application_name=application_name, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + application_name: str, + *, + client: Client, + body: Application, +) -> Response[Union[Application, Error]]: + """Update application + + Updates an application's configuration and triggers a new deployment. Changes to runtime settings, + environment variables, or scaling parameters will be applied on the next deployment. + + Args: + application_name (str): + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Application, Error]] + """ + + kwargs = _get_kwargs( + application_name=application_name, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_name: str, + *, + client: Client, + body: Application, +) -> Union[Application, Error] | None: + """Update application + + Updates an application's configuration and triggers a new deployment. Changes to runtime settings, + environment variables, or scaling parameters will be applied on the next deployment. + + Args: + application_name (str): + body (Application): Long-running application deployment that runs your custom code as a + publicly accessible endpoint. Applications are always public and use mk3 generation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Application, Error] + """ + + return ( + await asyncio_detailed( + application_name=application_name, + client=client, + body=body, + ) + ).parsed diff --git a/src/blaxel/core/client/api/customdomains/create_application_custom_domain.py b/src/blaxel/core/client/api/customdomains/create_application_custom_domain.py new file mode 100644 index 00000000..f865b7e8 --- /dev/null +++ b/src/blaxel/core/client/api/customdomains/create_application_custom_domain.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import Client +from ...models.custom_domain import CustomDomain +from ...types import Response + + +def _get_kwargs( + application_name: str, + *, + body: CustomDomain, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/applications/{application_name}/customdomains", + } + + if type(body) is dict: + _body = body + else: + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: Client, response: httpx.Response) -> CustomDomain | None: + if response.status_code == 200: + response_200 = CustomDomain.from_dict(response.json()) + + return response_200 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: Client, response: httpx.Response) -> Response[CustomDomain]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_name: str, + *, + client: Client, + body: CustomDomain, +) -> Response[CustomDomain]: + """Create application custom domain + + Creates a new custom domain scoped to a specific application. After creation, you must configure DNS + records and verify domain ownership before it becomes active. + + Args: + application_name (str): + body (CustomDomain): Custom domain for preview deployments + The custom domain represents a base domain (e.g., example.com) that will be used + to serve preview deployments. Each preview will be accessible at a subdomain: + .preview. (e.g., abc123.preview.example.com) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CustomDomain] + """ + + kwargs = _get_kwargs( + application_name=application_name, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_name: str, + *, + client: Client, + body: CustomDomain, +) -> CustomDomain | None: + """Create application custom domain + + Creates a new custom domain scoped to a specific application. After creation, you must configure DNS + records and verify domain ownership before it becomes active. + + Args: + application_name (str): + body (CustomDomain): Custom domain for preview deployments + The custom domain represents a base domain (e.g., example.com) that will be used + to serve preview deployments. Each preview will be accessible at a subdomain: + .preview. (e.g., abc123.preview.example.com) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CustomDomain + """ + + return sync_detailed( + application_name=application_name, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + application_name: str, + *, + client: Client, + body: CustomDomain, +) -> Response[CustomDomain]: + """Create application custom domain + + Creates a new custom domain scoped to a specific application. After creation, you must configure DNS + records and verify domain ownership before it becomes active. + + Args: + application_name (str): + body (CustomDomain): Custom domain for preview deployments + The custom domain represents a base domain (e.g., example.com) that will be used + to serve preview deployments. Each preview will be accessible at a subdomain: + .preview. (e.g., abc123.preview.example.com) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CustomDomain] + """ + + kwargs = _get_kwargs( + application_name=application_name, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_name: str, + *, + client: Client, + body: CustomDomain, +) -> CustomDomain | None: + """Create application custom domain + + Creates a new custom domain scoped to a specific application. After creation, you must configure DNS + records and verify domain ownership before it becomes active. + + Args: + application_name (str): + body (CustomDomain): Custom domain for preview deployments + The custom domain represents a base domain (e.g., example.com) that will be used + to serve preview deployments. Each preview will be accessible at a subdomain: + .preview. (e.g., abc123.preview.example.com) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CustomDomain + """ + + return ( + await asyncio_detailed( + application_name=application_name, + client=client, + body=body, + ) + ).parsed diff --git a/src/blaxel/core/client/client.py b/src/blaxel/core/client/client.py index 4a5bd452..00f5eb33 100644 --- a/src/blaxel/core/client/client.py +++ b/src/blaxel/core/client/client.py @@ -64,13 +64,12 @@ def with_base_url(self, base_url: str) -> "Client": def with_headers(self, headers: dict[str, str]) -> "Client": """Get a new client matching this one with additional headers""" - merged_headers = {**self._headers, **headers} - self._headers = merged_headers + self._headers = headers if self._client is not None: self._client.headers.update(headers) if self._async_client is not None: self._async_client.headers.update(headers) - return evolve(self, headers=merged_headers) + return evolve(self, headers={**self._headers, **headers}) def with_cookies(self, cookies: dict[str, str]) -> "Client": """Get a new client matching this one with additional cookies""" diff --git a/src/blaxel/core/client/models/__init__.py b/src/blaxel/core/client/models/__init__.py index f17d253f..041fc2ee 100644 --- a/src/blaxel/core/client/models/__init__.py +++ b/src/blaxel/core/client/models/__init__.py @@ -6,6 +6,12 @@ from .agent_runtime_generation import AgentRuntimeGeneration from .agent_spec import AgentSpec from .api_key import ApiKey +from .app_revision import AppRevision +from .app_revision_configuration import AppRevisionConfiguration +from .app_url import AppUrl +from .application import Application +from .application_list import ApplicationList +from .application_spec import ApplicationSpec from .check_workspace_availability_body import CheckWorkspaceAvailabilityBody from .cleanup_images_response_200 import CleanupImagesResponse200 from .configuration import Configuration @@ -27,14 +33,19 @@ from .custom_domain_metadata import CustomDomainMetadata from .custom_domain_spec import CustomDomainSpec from .custom_domain_spec_status import CustomDomainSpecStatus +from .custom_domain_spec_subject_alternative_names_item import ( + CustomDomainSpecSubjectAlternativeNamesItem, +) from .custom_domain_spec_txt_records import CustomDomainSpecTxtRecords -from .custom_domain_subdomain import CustomDomainSubdomain from .delete_drive_response_200 import DeleteDriveResponse200 from .delete_sandbox_preview_token_response_200 import DeleteSandboxPreviewTokenResponse200 from .delete_volume_template_version_response_200 import DeleteVolumeTemplateVersionResponse200 from .delete_workspace_service_account_response_200 import DeleteWorkspaceServiceAccountResponse200 from .drive import Drive from .drive_list import DriveList +from .drive_permission import DrivePermission +from .drive_permission_labels import DrivePermissionLabels +from .drive_permission_mode import DrivePermissionMode from .drive_spec import DriveSpec from .drive_state import DriveState from .egress_config import EgressConfig @@ -121,6 +132,8 @@ from .job_volume_type import JobVolumeType from .list_agents_anchor import ListAgentsAnchor from .list_agents_sort import ListAgentsSort +from .list_applications_anchor import ListApplicationsAnchor +from .list_applications_sort import ListApplicationsSort from .list_drives_anchor import ListDrivesAnchor from .list_drives_sort import ListDrivesSort from .list_functions_anchor import ListFunctionsAnchor @@ -269,6 +282,12 @@ "AgentRuntimeGeneration", "AgentSpec", "ApiKey", + "Application", + "ApplicationList", + "ApplicationSpec", + "AppRevision", + "AppRevisionConfiguration", + "AppUrl", "CheckWorkspaceAvailabilityBody", "CleanupImagesResponse200", "Configuration", @@ -290,14 +309,17 @@ "CustomDomainMetadata", "CustomDomainSpec", "CustomDomainSpecStatus", + "CustomDomainSpecSubjectAlternativeNamesItem", "CustomDomainSpecTxtRecords", - "CustomDomainSubdomain", "DeleteDriveResponse200", "DeleteSandboxPreviewTokenResponse200", "DeleteVolumeTemplateVersionResponse200", "DeleteWorkspaceServiceAccountResponse200", "Drive", "DriveList", + "DrivePermission", + "DrivePermissionLabels", + "DrivePermissionMode", "DriveSpec", "DriveState", "EgressConfig", @@ -382,6 +404,8 @@ "JobVolumeType", "ListAgentsAnchor", "ListAgentsSort", + "ListApplicationsAnchor", + "ListApplicationsSort", "ListDrivesAnchor", "ListDrivesSort", "ListFunctionsAnchor", diff --git a/src/blaxel/core/client/models/app_revision.py b/src/blaxel/core/client/models/app_revision.py new file mode 100644 index 00000000..de8acac5 --- /dev/null +++ b/src/blaxel/core/client/models/app_revision.py @@ -0,0 +1,139 @@ +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.env import Env + + +T = TypeVar("T", bound="AppRevision") + + +@_attrs_define +class AppRevision: + """A single application revision containing the deployed image and configuration + + Attributes: + image (str): Container image for this revision (mandatory) + created_at (Union[Unset, str]): When this revision was created + created_by (Union[Unset, str]): Who created this revision + envs (Union[Unset, list['Env']]): Environment variables for this revision + id (Union[Unset, str]): Unique revision identifier + memory (Union[Unset, int]): Memory allocation in megabytes. Determines CPU allocation (CPU = memory / 2048). + Example: 2048. + port (Union[Unset, int]): Port the application listens on for this revision (default uses spec-level port or + 8080) Example: 8080. + """ + + image: str + created_at: Union[Unset, str] = UNSET + created_by: Union[Unset, str] = UNSET + envs: Union[Unset, list["Env"]] = UNSET + id: Union[Unset, str] = UNSET + memory: Union[Unset, int] = UNSET + port: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + image = self.image + + created_at = self.created_at + + created_by = self.created_by + + envs: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.envs, Unset): + envs = [] + for envs_item_data in self.envs: + if type(envs_item_data) is dict: + envs_item = envs_item_data + else: + envs_item = envs_item_data.to_dict() + envs.append(envs_item) + + id = self.id + + memory = self.memory + + port = self.port + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "image": image, + } + ) + if created_at is not UNSET: + field_dict["createdAt"] = created_at + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if envs is not UNSET: + field_dict["envs"] = envs + if id is not UNSET: + field_dict["id"] = id + if memory is not UNSET: + field_dict["memory"] = memory + if port is not UNSET: + field_dict["port"] = port + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.env import Env + + if not src_dict: + return None + d = src_dict.copy() + image = d.pop("image") + + created_at = d.pop("createdAt", d.pop("created_at", UNSET)) + + created_by = d.pop("createdBy", d.pop("created_by", UNSET)) + + envs = [] + _envs = d.pop("envs", UNSET) + for envs_item_data in _envs or []: + envs_item = Env.from_dict(envs_item_data) + + envs.append(envs_item) + + id = d.pop("id", UNSET) + + memory = d.pop("memory", UNSET) + + port = d.pop("port", UNSET) + + app_revision = cls( + image=image, + created_at=created_at, + created_by=created_by, + envs=envs, + id=id, + memory=memory, + port=port, + ) + + app_revision.additional_properties = d + return app_revision + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/app_revision_configuration.py b/src/blaxel/core/client/models/app_revision_configuration.py new file mode 100644 index 00000000..e6a0d80b --- /dev/null +++ b/src/blaxel/core/client/models/app_revision_configuration.py @@ -0,0 +1,97 @@ +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AppRevisionConfiguration") + + +@_attrs_define +class AppRevisionConfiguration: + """Routing configuration controlling which revision is active and canary traffic splitting + + Attributes: + active (Union[Unset, str]): Active revision id + canary (Union[Unset, str]): Canary revision id + canary_percent (Union[Unset, int]): Canary revision percent (0-100) Example: 10. + sticky_session_ttl (Union[Unset, int]): Sticky session TTL in seconds (0 = disabled) + traffic (Union[Unset, int]): Traffic percentage for deployment Example: 100. + """ + + active: Union[Unset, str] = UNSET + canary: Union[Unset, str] = UNSET + canary_percent: Union[Unset, int] = UNSET + sticky_session_ttl: Union[Unset, int] = UNSET + traffic: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + active = self.active + + canary = self.canary + + canary_percent = self.canary_percent + + sticky_session_ttl = self.sticky_session_ttl + + traffic = self.traffic + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if active is not UNSET: + field_dict["active"] = active + if canary is not UNSET: + field_dict["canary"] = canary + if canary_percent is not UNSET: + field_dict["canaryPercent"] = canary_percent + if sticky_session_ttl is not UNSET: + field_dict["stickySessionTtl"] = sticky_session_ttl + if traffic is not UNSET: + field_dict["traffic"] = traffic + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + if not src_dict: + return None + d = src_dict.copy() + active = d.pop("active", UNSET) + + canary = d.pop("canary", UNSET) + + canary_percent = d.pop("canaryPercent", d.pop("canary_percent", UNSET)) + + sticky_session_ttl = d.pop("stickySessionTtl", d.pop("sticky_session_ttl", UNSET)) + + traffic = d.pop("traffic", UNSET) + + app_revision_configuration = cls( + active=active, + canary=canary, + canary_percent=canary_percent, + sticky_session_ttl=sticky_session_ttl, + traffic=traffic, + ) + + app_revision_configuration.additional_properties = d + return app_revision_configuration + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/app_url.py b/src/blaxel/core/client/models/app_url.py new file mode 100644 index 00000000..7c38302b --- /dev/null +++ b/src/blaxel/core/client/models/app_url.py @@ -0,0 +1,76 @@ +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AppUrl") + + +@_attrs_define +class AppUrl: + """A single URL entry for the application. If the domain is a wildcard custom domain (e.g. *.sandbox.vybe.build), use + subdomain to pick a specific subdomain. If the domain is a direct custom domain (e.g. app.vybe.build), subdomain is + not needed. + + Attributes: + domain (str): Custom domain (must be a verified custom domain in the workspace). Can be a wildcard domain (e.g. + sandbox.vybe.build registered as *.sandbox.vybe.build) or a direct domain (e.g. app.vybe.build). Example: + app.example.com. + subdomain (Union[Unset, str]): Subdomain to use with a wildcard custom domain (optional) Example: www. + """ + + domain: str + subdomain: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + domain = self.domain + + subdomain = self.subdomain + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "domain": domain, + } + ) + if subdomain is not UNSET: + field_dict["subdomain"] = subdomain + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + if not src_dict: + return None + d = src_dict.copy() + domain = d.pop("domain") + + subdomain = d.pop("subdomain", UNSET) + + app_url = cls( + domain=domain, + subdomain=subdomain, + ) + + app_url.additional_properties = d + return app_url + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/application.py b/src/blaxel/core/client/models/application.py new file mode 100644 index 00000000..cbf8fb57 --- /dev/null +++ b/src/blaxel/core/client/models/application.py @@ -0,0 +1,126 @@ +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.application_spec import ApplicationSpec + from ..models.core_event import CoreEvent + from ..models.metadata import Metadata + + +T = TypeVar("T", bound="Application") + + +@_attrs_define +class Application: + """Long-running application deployment that runs your custom code as a publicly accessible endpoint. Applications are + always public and use mk3 generation. + + Attributes: + metadata (Metadata): Common metadata fields shared by all Blaxel resources including name, labels, timestamps, + and ownership information + spec (ApplicationSpec): Configuration for an application including revision management, URL routing, and + deployment region + events (Union[Unset, list['CoreEvent']]): Events happening on a resource deployed on Blaxel + status (Union[Unset, str]): Application status computed from events + """ + + metadata: "Metadata" + spec: "ApplicationSpec" + events: Union[Unset, list["CoreEvent"]] = UNSET + status: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + if type(self.metadata) is dict: + metadata = self.metadata + else: + metadata = self.metadata.to_dict() + + if type(self.spec) is dict: + spec = self.spec + else: + spec = self.spec.to_dict() + + events: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.events, Unset): + events = [] + for componentsschemas_core_events_item_data in self.events: + if type(componentsschemas_core_events_item_data) is dict: + componentsschemas_core_events_item = componentsschemas_core_events_item_data + else: + componentsschemas_core_events_item = ( + componentsschemas_core_events_item_data.to_dict() + ) + events.append(componentsschemas_core_events_item) + + status = self.status + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "metadata": metadata, + "spec": spec, + } + ) + if events is not UNSET: + field_dict["events"] = events + if status is not UNSET: + field_dict["status"] = status + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.application_spec import ApplicationSpec + from ..models.core_event import CoreEvent + from ..models.metadata import Metadata + + if not src_dict: + return None + d = src_dict.copy() + metadata = Metadata.from_dict(d.pop("metadata")) + + spec = ApplicationSpec.from_dict(d.pop("spec")) + + events = [] + _events = d.pop("events", UNSET) + for componentsschemas_core_events_item_data in _events or []: + componentsschemas_core_events_item = CoreEvent.from_dict( + componentsschemas_core_events_item_data + ) + + events.append(componentsschemas_core_events_item) + + status = d.pop("status", UNSET) + + application = cls( + metadata=metadata, + spec=spec, + events=events, + status=status, + ) + + application.additional_properties = d + return application + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/application_list.py b/src/blaxel/core/client/models/application_list.py new file mode 100644 index 00000000..a9cb0b6b --- /dev/null +++ b/src/blaxel/core/client/models/application_list.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..pagination import split_list_response +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.application import Application + from ..models.pagination_meta import PaginationMeta + + +T = TypeVar("T", bound="ApplicationList") + + +@_attrs_define +class ApplicationList: + """Cursor-paginated list of applications. Returned starting with API version 2026-04-28; older API versions return a + bare array of applications instead. + + Attributes: + data (Union[Unset, list['Application']]): Page of applications. + meta (Union[Unset, PaginationMeta]): Pagination metadata returned alongside a page of listing results. Always + present on listing endpoints starting with API version 2026-04-28. + """ + + data: Union[Unset, list["Application"]] = UNSET + meta: Union[Unset, "PaginationMeta"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + data: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.data, Unset): + data = [] + for data_item_data in self.data: + if type(data_item_data) is dict: + data_item = data_item_data + else: + data_item = data_item_data.to_dict() + data.append(data_item) + + meta: Union[Unset, dict[str, Any]] = UNSET + if self.meta and not isinstance(self.meta, Unset) and not isinstance(self.meta, dict): + meta = self.meta.to_dict() + elif self.meta and isinstance(self.meta, dict): + meta = self.meta + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if data is not UNSET: + field_dict["data"] = data + if meta is not UNSET: + field_dict["meta"] = meta + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any] | list[Any]) -> T | None: + from ..models.application import Application + from ..models.pagination_meta import PaginationMeta + + _data, _meta, additional_properties = split_list_response(src_dict) + if not _data and not additional_properties and isinstance(_meta, Unset): + return None + d = {"data": _data, "meta": _meta} + data = [] + _data = d.pop("data", UNSET) + for data_item_data in _data or []: + data_item = Application.from_dict(data_item_data) + + data.append(data_item) + + _meta = d.pop("meta", UNSET) + meta: Union[Unset, PaginationMeta] + if isinstance(_meta, Unset): + meta = UNSET + else: + meta = PaginationMeta.from_dict(_meta) + + application_list = cls( + data=data, + meta=meta, + ) + + application_list.additional_properties = additional_properties + return application_list + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/application_spec.py b/src/blaxel/core/client/models/application_spec.py new file mode 100644 index 00000000..8fa0d2f3 --- /dev/null +++ b/src/blaxel/core/client/models/application_spec.py @@ -0,0 +1,164 @@ +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.app_revision import AppRevision + from ..models.app_revision_configuration import AppRevisionConfiguration + from ..models.app_url import AppUrl + + +T = TypeVar("T", bound="ApplicationSpec") + + +@_attrs_define +class ApplicationSpec: + """Configuration for an application including revision management, URL routing, and deployment region + + Attributes: + enabled (Union[Unset, bool]): When false, the application is disabled and will not serve requests Default: True. + Example: True. + port (Union[Unset, int]): Port the application listens on (default 8080) Example: 8080. + region (Union[Unset, str]): Region where the application is deployed (e.g. us-pdx-1, eu-lon-1) Example: us- + pdx-1. + revision (Union[Unset, AppRevisionConfiguration]): Routing configuration controlling which revision is active + and canary traffic splitting + revisions (Union[Unset, list['AppRevision']]): + urls (Union[Unset, list['AppUrl']]): URL configuration for the application. Each entry defines a custom URL + through which the application is accessible. The domain must be a verified custom domain in the workspace. + """ + + enabled: Union[Unset, bool] = True + port: Union[Unset, int] = UNSET + region: Union[Unset, str] = UNSET + revision: Union[Unset, "AppRevisionConfiguration"] = UNSET + revisions: Union[Unset, list["AppRevision"]] = UNSET + urls: Union[Unset, list["AppUrl"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + enabled = self.enabled + + port = self.port + + region = self.region + + revision: Union[Unset, dict[str, Any]] = UNSET + if ( + self.revision + and not isinstance(self.revision, Unset) + and not isinstance(self.revision, dict) + ): + revision = self.revision.to_dict() + elif self.revision and isinstance(self.revision, dict): + revision = self.revision + + revisions: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.revisions, Unset): + revisions = [] + for componentsschemas_app_revisions_item_data in self.revisions: + if type(componentsschemas_app_revisions_item_data) is dict: + componentsschemas_app_revisions_item = componentsschemas_app_revisions_item_data + else: + componentsschemas_app_revisions_item = ( + componentsschemas_app_revisions_item_data.to_dict() + ) + revisions.append(componentsschemas_app_revisions_item) + + urls: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.urls, Unset): + urls = [] + for componentsschemas_app_urls_item_data in self.urls: + if type(componentsschemas_app_urls_item_data) is dict: + componentsschemas_app_urls_item = componentsschemas_app_urls_item_data + else: + componentsschemas_app_urls_item = componentsschemas_app_urls_item_data.to_dict() + urls.append(componentsschemas_app_urls_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if enabled is not UNSET: + field_dict["enabled"] = enabled + if port is not UNSET: + field_dict["port"] = port + if region is not UNSET: + field_dict["region"] = region + if revision is not UNSET: + field_dict["revision"] = revision + if revisions is not UNSET: + field_dict["revisions"] = revisions + if urls is not UNSET: + field_dict["urls"] = urls + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.app_revision import AppRevision + from ..models.app_revision_configuration import AppRevisionConfiguration + from ..models.app_url import AppUrl + + if not src_dict: + return None + d = src_dict.copy() + enabled = d.pop("enabled", UNSET) + + port = d.pop("port", UNSET) + + region = d.pop("region", UNSET) + + _revision = d.pop("revision", UNSET) + revision: Union[Unset, AppRevisionConfiguration] + if isinstance(_revision, Unset): + revision = UNSET + else: + revision = AppRevisionConfiguration.from_dict(_revision) + + revisions = [] + _revisions = d.pop("revisions", UNSET) + for componentsschemas_app_revisions_item_data in _revisions or []: + componentsschemas_app_revisions_item = AppRevision.from_dict( + componentsschemas_app_revisions_item_data + ) + + revisions.append(componentsschemas_app_revisions_item) + + urls = [] + _urls = d.pop("urls", UNSET) + for componentsschemas_app_urls_item_data in _urls or []: + componentsschemas_app_urls_item = AppUrl.from_dict(componentsschemas_app_urls_item_data) + + urls.append(componentsschemas_app_urls_item) + + application_spec = cls( + enabled=enabled, + port=port, + region=region, + revision=revision, + revisions=revisions, + urls=urls, + ) + + application_spec.additional_properties = d + return application_spec + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/custom_domain_spec.py b/src/blaxel/core/client/models/custom_domain_spec.py index 375a5182..db5d656f 100644 --- a/src/blaxel/core/client/models/custom_domain_spec.py +++ b/src/blaxel/core/client/models/custom_domain_spec.py @@ -7,8 +7,10 @@ from ..types import UNSET, Unset if TYPE_CHECKING: + from ..models.custom_domain_spec_subject_alternative_names_item import ( + CustomDomainSpecSubjectAlternativeNamesItem, + ) from ..models.custom_domain_spec_txt_records import CustomDomainSpecTxtRecords - from ..models.custom_domain_subdomain import CustomDomainSubdomain T = TypeVar("T", bound="CustomDomainSpec") @@ -26,8 +28,8 @@ class CustomDomainSpec: region (Union[Unset, str]): Region that the custom domain is associated with Example: us-pdx-1. status (Union[Unset, CustomDomainSpecStatus]): Current status of the domain (pending, verified, failed) Example: verified. - subdomains (Union[Unset, list['CustomDomainSubdomain']]): List of subdomains (previews) currently using this - custom domain. Only populated on GET /customdomains/{domainName}. + subject_alternative_names (Union[Unset, list['CustomDomainSpecSubjectAlternativeNamesItem']]): Subject + Alternative Names (SANs) for the ACM certificate. Only applicable for application domains. txt_records (Union[Unset, CustomDomainSpecTxtRecords]): Map of TXT record names to values for domain verification verification_error (Union[Unset, str]): Error message if verification failed @@ -38,7 +40,9 @@ class CustomDomainSpec: last_verified_at: Union[Unset, str] = UNSET region: Union[Unset, str] = UNSET status: Union[Unset, CustomDomainSpecStatus] = UNSET - subdomains: Union[Unset, list["CustomDomainSubdomain"]] = UNSET + subject_alternative_names: Union[Unset, list["CustomDomainSpecSubjectAlternativeNamesItem"]] = ( + UNSET + ) txt_records: Union[Unset, "CustomDomainSpecTxtRecords"] = UNSET verification_error: Union[Unset, str] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -57,15 +61,15 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.status, Unset): status = self.status.value - subdomains: Union[Unset, list[dict[str, Any]]] = UNSET - if not isinstance(self.subdomains, Unset): - subdomains = [] - for subdomains_item_data in self.subdomains: - if type(subdomains_item_data) is dict: - subdomains_item = subdomains_item_data + subject_alternative_names: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.subject_alternative_names, Unset): + subject_alternative_names = [] + for subject_alternative_names_item_data in self.subject_alternative_names: + if type(subject_alternative_names_item_data) is dict: + subject_alternative_names_item = subject_alternative_names_item_data else: - subdomains_item = subdomains_item_data.to_dict() - subdomains.append(subdomains_item) + subject_alternative_names_item = subject_alternative_names_item_data.to_dict() + subject_alternative_names.append(subject_alternative_names_item) txt_records: Union[Unset, dict[str, Any]] = UNSET if ( @@ -92,8 +96,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["region"] = region if status is not UNSET: field_dict["status"] = status - if subdomains is not UNSET: - field_dict["subdomains"] = subdomains + if subject_alternative_names is not UNSET: + field_dict["subjectAlternativeNames"] = subject_alternative_names if txt_records is not UNSET: field_dict["txtRecords"] = txt_records if verification_error is not UNSET: @@ -103,8 +107,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.custom_domain_spec_subject_alternative_names_item import ( + CustomDomainSpecSubjectAlternativeNamesItem, + ) from ..models.custom_domain_spec_txt_records import CustomDomainSpecTxtRecords - from ..models.custom_domain_subdomain import CustomDomainSubdomain if not src_dict: return None @@ -124,12 +130,16 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: else: status = CustomDomainSpecStatus(_status) - subdomains = [] - _subdomains = d.pop("subdomains", UNSET) - for subdomains_item_data in _subdomains or []: - subdomains_item = CustomDomainSubdomain.from_dict(subdomains_item_data) + subject_alternative_names = [] + _subject_alternative_names = d.pop( + "subjectAlternativeNames", d.pop("subject_alternative_names", UNSET) + ) + for subject_alternative_names_item_data in _subject_alternative_names or []: + subject_alternative_names_item = CustomDomainSpecSubjectAlternativeNamesItem.from_dict( + subject_alternative_names_item_data + ) - subdomains.append(subdomains_item) + subject_alternative_names.append(subject_alternative_names_item) _txt_records = d.pop("txtRecords", d.pop("txt_records", UNSET)) txt_records: Union[Unset, CustomDomainSpecTxtRecords] @@ -146,7 +156,7 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: last_verified_at=last_verified_at, region=region, status=status, - subdomains=subdomains, + subject_alternative_names=subject_alternative_names, txt_records=txt_records, verification_error=verification_error, ) diff --git a/src/blaxel/core/client/models/custom_domain_spec_subject_alternative_names_item.py b/src/blaxel/core/client/models/custom_domain_spec_subject_alternative_names_item.py new file mode 100644 index 00000000..c621083b --- /dev/null +++ b/src/blaxel/core/client/models/custom_domain_spec_subject_alternative_names_item.py @@ -0,0 +1,45 @@ +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="CustomDomainSpecSubjectAlternativeNamesItem") + + +@_attrs_define +class CustomDomainSpecSubjectAlternativeNamesItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + if not src_dict: + return None + d = src_dict.copy() + custom_domain_spec_subject_alternative_names_item = cls() + + custom_domain_spec_subject_alternative_names_item.additional_properties = d + return custom_domain_spec_subject_alternative_names_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/custom_domain_subdomain.py b/src/blaxel/core/client/models/custom_domain_subdomain.py deleted file mode 100644 index aaf35848..00000000 --- a/src/blaxel/core/client/models/custom_domain_subdomain.py +++ /dev/null @@ -1,97 +0,0 @@ -from typing import Any, TypeVar, Union - -from attrs import define as _attrs_define -from attrs import field as _attrs_field - -from ..types import UNSET, Unset - -T = TypeVar("T", bound="CustomDomainSubdomain") - - -@_attrs_define -class CustomDomainSubdomain: - """A subdomain (preview) using a custom domain - - Attributes: - preview_name (Union[Unset, str]): Preview name - resource_name (Union[Unset, str]): Resource name - resource_type (Union[Unset, str]): Resource type (e.g., sandbox) - subdomain (Union[Unset, str]): Subdomain prefix used for routing - url (Union[Unset, str]): Full URL of the preview on this custom domain - """ - - preview_name: Union[Unset, str] = UNSET - resource_name: Union[Unset, str] = UNSET - resource_type: Union[Unset, str] = UNSET - subdomain: Union[Unset, str] = UNSET - url: Union[Unset, str] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - preview_name = self.preview_name - - resource_name = self.resource_name - - resource_type = self.resource_type - - subdomain = self.subdomain - - url = self.url - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update({}) - if preview_name is not UNSET: - field_dict["previewName"] = preview_name - if resource_name is not UNSET: - field_dict["resourceName"] = resource_name - if resource_type is not UNSET: - field_dict["resourceType"] = resource_type - if subdomain is not UNSET: - field_dict["subdomain"] = subdomain - if url is not UNSET: - field_dict["url"] = url - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: - if not src_dict: - return None - d = src_dict.copy() - preview_name = d.pop("previewName", d.pop("preview_name", UNSET)) - - resource_name = d.pop("resourceName", d.pop("resource_name", UNSET)) - - resource_type = d.pop("resourceType", d.pop("resource_type", UNSET)) - - subdomain = d.pop("subdomain", UNSET) - - url = d.pop("url", UNSET) - - custom_domain_subdomain = cls( - preview_name=preview_name, - resource_name=resource_name, - resource_type=resource_type, - subdomain=subdomain, - url=url, - ) - - custom_domain_subdomain.additional_properties = d - return custom_domain_subdomain - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/blaxel/core/client/models/drive_permission.py b/src/blaxel/core/client/models/drive_permission.py new file mode 100644 index 00000000..13937bd7 --- /dev/null +++ b/src/blaxel/core/client/models/drive_permission.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.drive_permission_mode import DrivePermissionMode +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.drive_permission_labels import DrivePermissionLabels + + +T = TypeVar("T", bound="DrivePermission") + + +@_attrs_define +class DrivePermission: + """Permission that controls which workloads can access a drive. A workload must have ALL specified labels (AND logic). + Permissions are evaluated with OR logic — the first matching permission grants access. + + Attributes: + labels (Union[Unset, DrivePermissionLabels]): Labels that the workload must have. All labels must match (AND + logic). Empty labels match all workloads. + mode (Union[Unset, DrivePermissionMode]): Access mode granted by this permission + path (Union[Unset, str]): Subfolder path to restrict access to. Defaults to / (full drive). Example: /data. + """ + + labels: Union[Unset, "DrivePermissionLabels"] = UNSET + mode: Union[Unset, DrivePermissionMode] = UNSET + path: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + labels: Union[Unset, dict[str, Any]] = UNSET + if self.labels and not isinstance(self.labels, Unset) and not isinstance(self.labels, dict): + labels = self.labels.to_dict() + elif self.labels and isinstance(self.labels, dict): + labels = self.labels + + mode: Union[Unset, str] = UNSET + if not isinstance(self.mode, Unset): + mode = self.mode.value + + path = self.path + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if labels is not UNSET: + field_dict["labels"] = labels + if mode is not UNSET: + field_dict["mode"] = mode + if path is not UNSET: + field_dict["path"] = path + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.drive_permission_labels import DrivePermissionLabels + + if not src_dict: + return None + d = src_dict.copy() + _labels = d.pop("labels", UNSET) + labels: Union[Unset, DrivePermissionLabels] + if isinstance(_labels, Unset): + labels = UNSET + else: + labels = DrivePermissionLabels.from_dict(_labels) + + _mode = d.pop("mode", UNSET) + mode: Union[Unset, DrivePermissionMode] + if isinstance(_mode, Unset): + mode = UNSET + else: + mode = DrivePermissionMode(_mode) + + path = d.pop("path", UNSET) + + drive_permission = cls( + labels=labels, + mode=mode, + path=path, + ) + + drive_permission.additional_properties = d + return drive_permission + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/drive_permission_labels.py b/src/blaxel/core/client/models/drive_permission_labels.py new file mode 100644 index 00000000..1594a4b7 --- /dev/null +++ b/src/blaxel/core/client/models/drive_permission_labels.py @@ -0,0 +1,45 @@ +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DrivePermissionLabels") + + +@_attrs_define +class DrivePermissionLabels: + """Labels that the workload must have. All labels must match (AND logic). Empty labels match all workloads.""" + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + if not src_dict: + return None + d = src_dict.copy() + drive_permission_labels = cls() + + drive_permission_labels.additional_properties = d + return drive_permission_labels + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/blaxel/core/client/models/drive_permission_mode.py b/src/blaxel/core/client/models/drive_permission_mode.py new file mode 100644 index 00000000..e7a2e27a --- /dev/null +++ b/src/blaxel/core/client/models/drive_permission_mode.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class DrivePermissionMode(str, Enum): + READ = "read" + READ_WRITE = "read-write" + + def __str__(self) -> str: + return str(self.value) + + @classmethod + def _missing_(cls, value: object) -> "DrivePermissionMode | None": + if isinstance(value, str): + upper_value = value.upper() + for member in cls: + if member.value.upper() == upper_value: + return member + return None diff --git a/src/blaxel/core/client/models/drive_spec.py b/src/blaxel/core/client/models/drive_spec.py index 36cf521b..6f412d3c 100644 --- a/src/blaxel/core/client/models/drive_spec.py +++ b/src/blaxel/core/client/models/drive_spec.py @@ -1,10 +1,14 @@ -from typing import Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.drive_permission import DrivePermission + + T = TypeVar("T", bound="DriveSpec") @@ -15,6 +19,8 @@ class DriveSpec: Attributes: infrastructure_id (Union[Unset, str]): The internal infrastructure resource identifier for this drive (bucket name) + permissions (Union[Unset, list['DrivePermission']]): Permissions controlling which workloads can access this + drive. Empty means all workloads in the workspace can access the drive. Maximum 3 permissions. region (Union[Unset, str]): Deployment region for the drive (e.g., us-pdx-1, eu-lon-1). Must match the region of resources it attaches to. Example: us-pdx-1. size (Union[Unset, int]): Optional size limit for the drive in GB. If not specified, drive has no size limit. @@ -22,13 +28,25 @@ class DriveSpec: """ infrastructure_id: Union[Unset, str] = UNSET + permissions: Union[Unset, list["DrivePermission"]] = UNSET region: Union[Unset, str] = UNSET size: Union[Unset, int] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + infrastructure_id = self.infrastructure_id + permissions: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.permissions, Unset): + permissions = [] + for permissions_item_data in self.permissions: + if type(permissions_item_data) is dict: + permissions_item = permissions_item_data + else: + permissions_item = permissions_item_data.to_dict() + permissions.append(permissions_item) + region = self.region size = self.size @@ -38,6 +56,8 @@ def to_dict(self) -> dict[str, Any]: field_dict.update({}) if infrastructure_id is not UNSET: field_dict["infrastructureId"] = infrastructure_id + if permissions is not UNSET: + field_dict["permissions"] = permissions if region is not UNSET: field_dict["region"] = region if size is not UNSET: @@ -47,17 +67,27 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: + from ..models.drive_permission import DrivePermission + if not src_dict: return None d = src_dict.copy() infrastructure_id = d.pop("infrastructureId", d.pop("infrastructure_id", UNSET)) + permissions = [] + _permissions = d.pop("permissions", UNSET) + for permissions_item_data in _permissions or []: + permissions_item = DrivePermission.from_dict(permissions_item_data) + + permissions.append(permissions_item) + region = d.pop("region", UNSET) size = d.pop("size", UNSET) drive_spec = cls( infrastructure_id=infrastructure_id, + permissions=permissions, region=region, size=size, ) diff --git a/src/blaxel/core/client/models/function_runtime.py b/src/blaxel/core/client/models/function_runtime.py index 9e2623ab..36bca735 100644 --- a/src/blaxel/core/client/models/function_runtime.py +++ b/src/blaxel/core/client/models/function_runtime.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from ..models.env import Env + from ..models.port import Port T = TypeVar("T", bound="FunctionRuntime") @@ -30,6 +31,7 @@ class FunctionRuntime: in MB / 2048, e.g., 4096MB = 2 CPUs). Example: 2048. min_scale (Union[Unset, int]): Minimum instances to keep warm. Set to 1+ to eliminate cold starts, 0 for scale- to-zero. + ports (Union[Unset, list['Port']]): Set of ports for a resource transport (Union[Unset, FunctionRuntimeTransport]): Transport compatibility for the MCP, can be "websocket" or "http-stream" Example: http-stream. """ @@ -40,6 +42,7 @@ class FunctionRuntime: max_scale: Union[Unset, int] = UNSET memory: Union[Unset, int] = UNSET min_scale: Union[Unset, int] = UNSET + ports: Union[Unset, list["Port"]] = UNSET transport: Union[Unset, FunctionRuntimeTransport] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -67,6 +70,16 @@ def to_dict(self) -> dict[str, Any]: min_scale = self.min_scale + ports: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.ports, Unset): + ports = [] + for componentsschemas_ports_item_data in self.ports: + if type(componentsschemas_ports_item_data) is dict: + componentsschemas_ports_item = componentsschemas_ports_item_data + else: + componentsschemas_ports_item = componentsschemas_ports_item_data.to_dict() + ports.append(componentsschemas_ports_item) + transport: Union[Unset, str] = UNSET if not isinstance(self.transport, Unset): transport = self.transport.value @@ -86,6 +99,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["memory"] = memory if min_scale is not UNSET: field_dict["minScale"] = min_scale + if ports is not UNSET: + field_dict["ports"] = ports if transport is not UNSET: field_dict["transport"] = transport @@ -94,6 +109,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: from ..models.env import Env + from ..models.port import Port if not src_dict: return None @@ -120,6 +136,13 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: min_scale = d.pop("minScale", d.pop("min_scale", UNSET)) + ports = [] + _ports = d.pop("ports", UNSET) + for componentsschemas_ports_item_data in _ports or []: + componentsschemas_ports_item = Port.from_dict(componentsschemas_ports_item_data) + + ports.append(componentsschemas_ports_item) + _transport = d.pop("transport", UNSET) transport: Union[Unset, FunctionRuntimeTransport] if isinstance(_transport, Unset): @@ -134,6 +157,7 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: max_scale=max_scale, memory=memory, min_scale=min_scale, + ports=ports, transport=transport, ) diff --git a/src/blaxel/core/client/models/list_applications_anchor.py b/src/blaxel/core/client/models/list_applications_anchor.py new file mode 100644 index 00000000..7ee504a8 --- /dev/null +++ b/src/blaxel/core/client/models/list_applications_anchor.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class ListApplicationsAnchor(str, Enum): + END = "end" + + def __str__(self) -> str: + return str(self.value) + + @classmethod + def _missing_(cls, value: object) -> "ListApplicationsAnchor | None": + if isinstance(value, str): + upper_value = value.upper() + for member in cls: + if member.value.upper() == upper_value: + return member + return None diff --git a/src/blaxel/core/client/models/list_applications_sort.py b/src/blaxel/core/client/models/list_applications_sort.py new file mode 100644 index 00000000..eefa1c38 --- /dev/null +++ b/src/blaxel/core/client/models/list_applications_sort.py @@ -0,0 +1,20 @@ +from enum import Enum + + +class ListApplicationsSort(str, Enum): + CREATEDATASC = "createdAt:asc" + CREATEDATDESC = "createdAt:desc" + NAMEASC = "name:asc" + NAMEDESC = "name:desc" + + def __str__(self) -> str: + return str(self.value) + + @classmethod + def _missing_(cls, value: object) -> "ListApplicationsSort | None": + if isinstance(value, str): + upper_value = value.upper() + for member in cls: + if member.value.upper() == upper_value: + return member + return None diff --git a/src/blaxel/core/client/models/owner_fields.py b/src/blaxel/core/client/models/owner_fields.py index 5b1f5d95..0c49274b 100644 --- a/src/blaxel/core/client/models/owner_fields.py +++ b/src/blaxel/core/client/models/owner_fields.py @@ -10,7 +10,7 @@ @_attrs_define class OwnerFields: - """Owner fields for Persistance + """Owner fields for Persistence Attributes: created_by (Union[Unset, str]): The user or service account who created the resource diff --git a/src/blaxel/core/client/models/policy_resource_type.py b/src/blaxel/core/client/models/policy_resource_type.py index 1b31d72c..28944091 100644 --- a/src/blaxel/core/client/models/policy_resource_type.py +++ b/src/blaxel/core/client/models/policy_resource_type.py @@ -3,6 +3,7 @@ class PolicyResourceType(str, Enum): AGENT = "agent" + APPLICATION = "application" FUNCTION = "function" MODEL = "model" SANDBOX = "sandbox" diff --git a/src/blaxel/core/client/models/sandbox_definition.py b/src/blaxel/core/client/models/sandbox_definition.py index 5074b15d..075da474 100644 --- a/src/blaxel/core/client/models/sandbox_definition.py +++ b/src/blaxel/core/client/models/sandbox_definition.py @@ -19,16 +19,16 @@ class SandboxDefinition: configurations Attributes: - categories (Union[Unset, list['SandboxDefinitionCategoriesItem']]): Categories of the defintion + categories (Union[Unset, list['SandboxDefinitionCategoriesItem']]): Categories of the definition coming_soon (Union[Unset, bool]): If the definition is coming soon - description (Union[Unset, str]): Description of the defintion Example: Python environment with data science + description (Union[Unset, str]): Description of the definition Example: Python environment with data science libraries pre-installed. display_name (Union[Unset, str]): Display name of the definition Example: Python Data Science. enterprise (Union[Unset, bool]): If the definition is enterprise hidden (Union[Unset, bool]): If the definition is hidden icon (Union[Unset, str]): Icon of the definition image (Union[Unset, str]): Image of the Sandbox definition Example: blaxel/python-data-science:latest. - long_description (Union[Unset, str]): Long description of the defintion + long_description (Union[Unset, str]): Long description of the definition memory (Union[Unset, int]): Memory of the Sandbox definition in MB Example: 2048. name (Union[Unset, str]): Name of the artifact Example: python-data-science. ports (Union[Unset, list['Port']]): Set of ports for a resource diff --git a/src/blaxel/core/client/models/time_fields.py b/src/blaxel/core/client/models/time_fields.py index a507c0ec..aa057d1e 100644 --- a/src/blaxel/core/client/models/time_fields.py +++ b/src/blaxel/core/client/models/time_fields.py @@ -10,7 +10,7 @@ @_attrs_define class TimeFields: - """Time fields for Persistance + """Time fields for Persistence Attributes: created_at (Union[Unset, str]): The date and time when the resource was created diff --git a/src/blaxel/core/client/models/workspace.py b/src/blaxel/core/client/models/workspace.py index 4cef1f2b..f56c5c8d 100644 --- a/src/blaxel/core/client/models/workspace.py +++ b/src/blaxel/core/client/models/workspace.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from ..models.group_workspace_mapping import GroupWorkspaceMapping from ..models.metadata_labels import MetadataLabels + from ..models.workspace_hipaa_info import WorkspaceHipaaInfo from ..models.workspace_resource_counts import WorkspaceResourceCounts from ..models.workspace_runtime import WorkspaceRuntime @@ -30,7 +31,10 @@ class Workspace: display_name (Union[Unset, str]): Workspace display name Example: My Workspace. group_mappings (Union[Unset, list['GroupWorkspaceMapping']]): Group-to-role mappings for directory sync (SCIM) group membership - hipaa_info (Union[Unset, Any]): + hipaa_info (Union[Unset, WorkspaceHipaaInfo]): HIPAA compliance state for a workspace. `accountEnabled` mirrors + the account-level `hipaa_compliance` addon (set server-side from operator tooling and Stripe billing events). + `unsafe` records a per-workspace opt-out toggled from workspace settings; absent when the account does not have + the addon. id (Union[Unset, str]): Autogenerated unique workspace id labels (Union[Unset, MetadataLabels]): Key-value pairs for organizing and filtering resources. Labels can be used to categorize resources by environment, project, team, or any custom taxonomy. @@ -52,7 +56,7 @@ class Workspace: account_id: Union[Unset, str] = UNSET display_name: Union[Unset, str] = UNSET group_mappings: Union[Unset, list["GroupWorkspaceMapping"]] = UNSET - hipaa_info: Union[Unset, Any] = UNSET + hipaa_info: Union[Unset, "WorkspaceHipaaInfo"] = UNSET id: Union[Unset, str] = UNSET labels: Union[Unset, "MetadataLabels"] = UNSET name: Union[Unset, str] = UNSET @@ -87,7 +91,15 @@ def to_dict(self) -> dict[str, Any]: group_mappings_item = group_mappings_item_data.to_dict() group_mappings.append(group_mappings_item) - hipaa_info = self.hipaa_info + hipaa_info: Union[Unset, dict[str, Any]] = UNSET + if ( + self.hipaa_info + and not isinstance(self.hipaa_info, Unset) + and not isinstance(self.hipaa_info, dict) + ): + hipaa_info = self.hipaa_info.to_dict() + elif self.hipaa_info and isinstance(self.hipaa_info, dict): + hipaa_info = self.hipaa_info id = self.id @@ -169,6 +181,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: from ..models.group_workspace_mapping import GroupWorkspaceMapping from ..models.metadata_labels import MetadataLabels + from ..models.workspace_hipaa_info import WorkspaceHipaaInfo from ..models.workspace_resource_counts import WorkspaceResourceCounts from ..models.workspace_runtime import WorkspaceRuntime @@ -194,7 +207,12 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T | None: group_mappings.append(group_mappings_item) - hipaa_info = d.pop("hipaaInfo", d.pop("hipaa_info", UNSET)) + _hipaa_info = d.pop("hipaaInfo", d.pop("hipaa_info", UNSET)) + hipaa_info: Union[Unset, WorkspaceHipaaInfo] + if isinstance(_hipaa_info, Unset): + hipaa_info = UNSET + else: + hipaa_info = WorkspaceHipaaInfo.from_dict(_hipaa_info) id = d.pop("id", UNSET) diff --git a/tests/core/test_logger.py b/tests/core/test_logger.py index f9b74246..84d8bd65 100644 --- a/tests/core/test_logger.py +++ b/tests/core/test_logger.py @@ -36,9 +36,7 @@ def restore_provider_loggers(): logger.propagate = state["propagate"] -def test_suppresses_high_risk_provider_loggers_by_default( - monkeypatch, restore_provider_loggers -): +def test_suppresses_high_risk_provider_loggers_by_default(monkeypatch, restore_provider_loggers): monkeypatch.delenv("BL_ALLOW_PROVIDER_DEBUG_LOGS", raising=False) for logger_name in PROVIDER_DEBUG_LOGGER_NAMES: @@ -78,9 +76,7 @@ def test_provider_debug_payload_patterns_do_not_emit_by_default( assert stream.getvalue() == "" -def test_provider_debug_opt_in_preserves_debug_behavior( - monkeypatch, restore_provider_loggers -): +def test_provider_debug_opt_in_preserves_debug_behavior(monkeypatch, restore_provider_loggers): monkeypatch.setenv("BL_ALLOW_PROVIDER_DEBUG_LOGS", "true") stream = io.StringIO() handler = logging.StreamHandler(stream) diff --git a/tests/integration/core/sandbox/proxy/test_claude.py b/tests/integration/core/sandbox/proxy/test_claude.py index fcf98c1d..21dbfe5c 100644 --- a/tests/integration/core/sandbox/proxy/test_claude.py +++ b/tests/integration/core/sandbox/proxy/test_claude.py @@ -15,15 +15,17 @@ reason="requires ANTHROPIC_API_KEY", ) -CLAUDE_ENV = " ".join([ - "export PATH=/usr/local/bin:/usr/bin:/bin", - "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY", - "HTTP_PROXY=$HTTP_PROXY", - "HTTPS_PROXY=$HTTPS_PROXY", - "NO_PROXY=$NO_PROXY", - "NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS", - "SSL_CERT_FILE=$SSL_CERT_FILE", -]) +CLAUDE_ENV = " ".join( + [ + "export PATH=/usr/local/bin:/usr/bin:/bin", + "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY", + "HTTP_PROXY=$HTTP_PROXY", + "HTTPS_PROXY=$HTTPS_PROXY", + "NO_PROXY=$NO_PROXY", + "NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE=$SSL_CERT_FILE", + ] +) @pytest.mark.asyncio(loop_scope="class") @@ -38,32 +40,36 @@ async def setup_sandbox(self, request): api_key = os.environ["ANTHROPIC_API_KEY"] request.cls.sandbox_name = unique_name("proxy-claude") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "envs": [{"name": "ANTHROPIC_API_KEY", "value": api_key}], - "network": { - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": {"X-Agent-Test": "claude-injected"}, - }, - ], + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "envs": [{"name": "ANTHROPIC_API_KEY", "value": api_key}], + "network": { + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": {"X-Agent-Test": "claude-injected"}, + }, + ], + }, }, - }, - }) + } + ) - setup = await request.cls.sandbox.process.exec({ - "command": ( - "apk add --no-cache curl bash 2>&1 && " - "npm install -g @anthropic-ai/claude-code 2>&1 && " - "adduser -D -s /bin/bash agent 2>&1" - ), - "wait_for_completion": True, - }) + setup = await request.cls.sandbox.process.exec( + { + "command": ( + "apk add --no-cache curl bash 2>&1 && " + "npm install -g @anthropic-ai/claude-code 2>&1 && " + "adduser -D -s /bin/bash agent 2>&1" + ), + "wait_for_completion": True, + } + ) if setup.exit_code != 0: raise RuntimeError(f"setup failed: {(setup.logs or '')[:500]}") @@ -74,28 +80,32 @@ async def setup_sandbox(self, request): pass async def test_agent_reaches_anthropic_api_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": ( - f'su - agent -c "{CLAUDE_ENV} && ' - "claude --dangerously-skip-permissions -p " - '\\"What is 2+2? Reply with ONLY the number.\\" ' - '--output-format text" 2>&1' - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + f'su - agent -c "{CLAUDE_ENV} && ' + "claude --dangerously-skip-permissions -p " + '\\"What is 2+2? Reply with ONLY the number.\\" ' + '--output-format text" 2>&1' + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert "4" in (result.logs or "") async def test_agent_makes_outbound_call_with_header_injection(self): - result = await self.sandbox.process.exec({ - "command": ( - f'su - agent -c "{CLAUDE_ENV} && ' - "claude --dangerously-skip-permissions -p " - '\\"Run: curl -s https://httpbin.org/headers — then print the full JSON output.\\" ' - '--output-format text" 2>&1' - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + f'su - agent -c "{CLAUDE_ENV} && ' + "claude --dangerously-skip-permissions -p " + '\\"Run: curl -s https://httpbin.org/headers — then print the full JSON output.\\" ' + '--output-format text" 2>&1' + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert "X-Agent-Test" in (result.logs or "") assert "claude-injected" in (result.logs or "") diff --git a/tests/integration/core/sandbox/proxy/test_cli_tools.py b/tests/integration/core/sandbox/proxy/test_cli_tools.py index 8097d7b7..9c1e2f10 100644 --- a/tests/integration/core/sandbox/proxy/test_cli_tools.py +++ b/tests/integration/core/sandbox/proxy/test_cli_tools.py @@ -21,46 +21,52 @@ class TestProxyCLITools: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("proxy-cli") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": { - "X-Proxy-Test": "header-injected", - "X-Api-Key": "{{SECRET:test-api-key}}", + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": { + "X-Proxy-Test": "header-injected", + "X-Api-Key": "{{SECRET:test-api-key}}", + }, + "body": { + "injected_field": "body-injected", + "secret_body": "{{SECRET:test-api-key}}", + }, + "secrets": {"test-api-key": "resolved-secret-42"}, }, - "body": { - "injected_field": "body-injected", - "secret_body": "{{SECRET:test-api-key}}", - }, - "secrets": {"test-api-key": "resolved-secret-42"}, - }, - ], + ], + }, }, - }, - }) + } + ) - install = await request.cls.sandbox.process.exec({ - "command": "apk add --no-cache curl wget git python3 py3-pip ca-certificates 2>&1", - "wait_for_completion": True, - }) + install = await request.cls.sandbox.process.exec( + { + "command": "apk add --no-cache curl wget git python3 py3-pip ca-certificates 2>&1", + "wait_for_completion": True, + } + ) if install.exit_code != 0: raise RuntimeError(f"apk install failed: {(install.logs or '')[:500]}") - cert_install = await request.cls.sandbox.process.exec({ - "command": ( - '[ -f "$SSL_CERT_FILE" ] && ' - 'cp "$SSL_CERT_FILE" /usr/local/share/ca-certificates/blaxel-proxy.crt && ' - 'update-ca-certificates 2>&1 || echo "no SSL_CERT_FILE"' - ), - "wait_for_completion": True, - }) + cert_install = await request.cls.sandbox.process.exec( + { + "command": ( + '[ -f "$SSL_CERT_FILE" ] && ' + 'cp "$SSL_CERT_FILE" /usr/local/share/ca-certificates/blaxel-proxy.crt && ' + 'update-ca-certificates 2>&1 || echo "no SSL_CERT_FILE"' + ), + "wait_for_completion": True, + } + ) if cert_install.exit_code != 0: raise RuntimeError(f"CA cert install failed: {(cert_install.logs or '')[:500]}") @@ -73,10 +79,12 @@ async def setup_sandbox(self, request): # ----- curl ----- async def test_curl_get_with_header_injection(self): - result = await self.sandbox.process.exec({ - "command": "curl -s https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "curl -s https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers.get("x-blaxel-request-id") is not None @@ -84,10 +92,12 @@ async def test_curl_get_with_header_injection(self): assert headers["x-api-key"] == "resolved-secret-42" async def test_curl_post_with_body_injection(self): - result = await self.sandbox.process.exec({ - "command": """curl -s -X POST https://httpbin.org/post -H "Content-Type: application/json" -d '{"user_data":"from-curl"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """curl -s -X POST https://httpbin.org/post -H "Content-Type: application/json" -d '{"user_data":"from-curl"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["user_data"] == "from-curl" @@ -98,10 +108,12 @@ async def test_curl_post_with_body_injection(self): assert headers["x-proxy-test"] == "header-injected" async def test_curl_preserves_user_headers(self): - result = await self.sandbox.process.exec({ - "command": 'curl -s -H "X-User-Custom: from-curl" https://httpbin.org/headers', - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": 'curl -s -H "X-User-Custom: from-curl" https://httpbin.org/headers', + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-user-custom"] == "from-curl" @@ -109,36 +121,47 @@ async def test_curl_preserves_user_headers(self): assert headers["x-api-key"] == "resolved-secret-42" async def test_curl_follows_redirects(self): - result = await self.sandbox.process.exec({ - "command": 'curl -s -L -o /dev/null -w "%{http_code}" https://httpbin.org/redirect/1', - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": 'curl -s -L -o /dev/null -w "%{http_code}" https://httpbin.org/redirect/1', + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert (result.logs or "").strip() == "200" async def test_curl_put_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": """curl -s -X PUT https://httpbin.org/put -H "Content-Type: application/json" -d '{"update":"from-curl"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """curl -s -X PUT https://httpbin.org/put -H "Content-Type: application/json" -d '{"update":"from-curl"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["update"] == "from-curl" assert lowercase_keys(response["headers"])["x-proxy-test"] == "header-injected" async def test_curl_delete_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": "curl -s -X DELETE https://httpbin.org/delete", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "curl -s -X DELETE https://httpbin.org/delete", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 - assert lowercase_keys(parse_json_output(result.logs)["headers"])["x-proxy-test"] == "header-injected" + assert ( + lowercase_keys(parse_json_output(result.logs)["headers"])["x-proxy-test"] + == "header-injected" + ) async def test_curl_handles_large_response(self): - result = await self.sandbox.process.exec({ - "command": 'curl -s -o /dev/null -w "%{http_code} %{size_download}" https://httpbin.org/bytes/10240', - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": 'curl -s -o /dev/null -w "%{http_code} %{size_download}" https://httpbin.org/bytes/10240', + "wait_for_completion": True, + } + ) assert result.exit_code == 0 status_code, size_str = (result.logs or "").strip().split(" ") assert status_code == "200" @@ -147,39 +170,47 @@ async def test_curl_handles_large_response(self): # ----- git ----- async def test_git_clone_public_repo(self): - result = await self.sandbox.process.exec({ - "command": ( - "export https_proxy=$HTTPS_PROXY http_proxy=$HTTP_PROXY && " - "GIT_SSL_CAINFO=$SSL_CERT_FILE git -c http.proxyAuthMethod=basic " - "clone --depth 1 https://github.com/octocat/Hello-World.git /tmp/git-test-repo 2>&1" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "export https_proxy=$HTTPS_PROXY http_proxy=$HTTP_PROXY && " + "GIT_SSL_CAINFO=$SSL_CERT_FILE git -c http.proxyAuthMethod=basic " + "clone --depth 1 https://github.com/octocat/Hello-World.git /tmp/git-test-repo 2>&1" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 - verify = await self.sandbox.process.exec({ - "command": "ls /tmp/git-test-repo/README", - "wait_for_completion": True, - }) + verify = await self.sandbox.process.exec( + { + "command": "ls /tmp/git-test-repo/README", + "wait_for_completion": True, + } + ) assert verify.exit_code == 0 async def test_git_ls_remote_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": ( - "export https_proxy=$HTTPS_PROXY http_proxy=$HTTP_PROXY && " - "GIT_SSL_CAINFO=$SSL_CERT_FILE git -c http.proxyAuthMethod=basic " - "ls-remote --heads https://github.com/octocat/Hello-World.git 2>&1" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "export https_proxy=$HTTPS_PROXY http_proxy=$HTTP_PROXY && " + "GIT_SSL_CAINFO=$SSL_CERT_FILE git -c http.proxyAuthMethod=basic " + "ls-remote --heads https://github.com/octocat/Hello-World.git 2>&1" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert "refs/heads/" in (result.logs or "") async def test_proxy_env_vars_visible_to_git(self): - result = await self.sandbox.process.exec({ - "command": "git config --global --list 2>&1; echo '---'; env | grep -i proxy || true", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "git config --global --list 2>&1; echo '---'; env | grep -i proxy || true", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 logs = result.logs or "" assert "proxy" in logs.lower() or "HTTPS_PROXY" in logs or "https_proxy" in logs @@ -187,13 +218,15 @@ async def test_proxy_env_vars_visible_to_git(self): # ----- pip ----- async def test_pip_install_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": ( - "pip3 install --break-system-packages --quiet six 2>&1 && " - 'python3 -c "import six; print(six.__version__)"' - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "pip3 install --break-system-packages --quiet six 2>&1 && " + 'python3 -c "import six; print(six.__version__)"' + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert len((result.logs or "").strip()) > 0 @@ -202,22 +235,28 @@ async def test_pip_install_through_proxy(self): async def test_npm_install_through_proxy(self): await self.sandbox.fs.write( "/tmp/npm-test/package.json", - json.dumps({ - "name": "proxy-npm-test", - "version": "1.0.0", - "dependencies": {"is-odd": "^3.0.1"}, - }), + json.dumps( + { + "name": "proxy-npm-test", + "version": "1.0.0", + "dependencies": {"is-odd": "^3.0.1"}, + } + ), ) - result = await self.sandbox.process.exec({ - "command": "cd /tmp/npm-test && npm install --no-audit --no-fund 2>&1", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "cd /tmp/npm-test && npm install --no-audit --no-fund 2>&1", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 - verify = await self.sandbox.process.exec({ - "command": """node -e "console.log(require('/tmp/npm-test/node_modules/is-odd')(3))" """, - "wait_for_completion": True, - }) + verify = await self.sandbox.process.exec( + { + "command": """node -e "console.log(require('/tmp/npm-test/node_modules/is-odd')(3))" """, + "wait_for_completion": True, + } + ) assert verify.exit_code == 0 assert (verify.logs or "").strip() == "true" diff --git a/tests/integration/core/sandbox/proxy/test_create.py b/tests/integration/core/sandbox/proxy/test_create.py index f048a9f8..aafcd527 100644 --- a/tests/integration/core/sandbox/proxy/test_create.py +++ b/tests/integration/core/sandbox/proxy/test_create.py @@ -12,26 +12,28 @@ async def test_creates_sandbox_with_proxy_routing_and_header_injection(created_sandboxes): name = unique_name("proxy-hdr") - sandbox = await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["api.stripe.com"], - "headers": { - "Authorization": "Bearer {{SECRET:stripe-key}}", - "Stripe-Version": "2024-12-18.acacia", + sandbox = await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["api.stripe.com"], + "headers": { + "Authorization": "Bearer {{SECRET:stripe-key}}", + "Stripe-Version": "2024-12-18.acacia", + }, + "secrets": {"stripe-key": "sk-live-test123"}, }, - "secrets": {"stripe-key": "sk-live-test123"}, - }, - ], + ], + }, }, - }, - }) + } + ) created_sandboxes.append(name) assert sandbox.metadata.name == name @@ -46,24 +48,26 @@ async def test_creates_sandbox_with_proxy_routing_and_header_injection(created_s async def test_creates_sandbox_with_proxy_body_injection(created_sandboxes): name = unique_name("proxy-body") - sandbox = await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["api.stripe.com"], - "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, - "body": {"api_key": "{{SECRET:stripe-key}}"}, - "secrets": {"stripe-key": "sk-live-test123"}, - }, - ], + sandbox = await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["api.stripe.com"], + "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, + "body": {"api_key": "{{SECRET:stripe-key}}"}, + "secrets": {"stripe-key": "sk-live-test123"}, + }, + ], + }, }, - }, - }) + } + ) created_sandboxes.append(name) route = sandbox.spec.network.proxy.routing[0] @@ -73,37 +77,39 @@ async def test_creates_sandbox_with_proxy_body_injection(created_sandboxes): async def test_creates_sandbox_with_multiple_proxy_routing_rules(created_sandboxes): name = unique_name("proxy-multi") - sandbox = await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["api.stripe.com"], - "headers": { - "Authorization": "Bearer {{SECRET:stripe-key}}", - "Stripe-Version": "2024-12-18.acacia", - "X-Request-Source": "blaxel-sandbox", + sandbox = await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["api.stripe.com"], + "headers": { + "Authorization": "Bearer {{SECRET:stripe-key}}", + "Stripe-Version": "2024-12-18.acacia", + "X-Request-Source": "blaxel-sandbox", + }, + "body": {"api_key": "{{SECRET:stripe-key}}"}, + "secrets": {"stripe-key": "sk-live-test123"}, }, - "body": {"api_key": "{{SECRET:stripe-key}}"}, - "secrets": {"stripe-key": "sk-live-test123"}, - }, - { - "destinations": ["api.openai.com"], - "headers": { - "Authorization": "Bearer {{SECRET:openai-key}}", - "OpenAI-Organization": "org-abc123", + { + "destinations": ["api.openai.com"], + "headers": { + "Authorization": "Bearer {{SECRET:openai-key}}", + "OpenAI-Organization": "org-abc123", + }, + "secrets": {"openai-key": "sk-proj-test789"}, }, - "secrets": {"openai-key": "sk-proj-test789"}, - }, - ], - "bypass": ["*.s3.amazonaws.com"], + ], + "bypass": ["*.s3.amazonaws.com"], + }, }, - }, - }) + } + ) created_sandboxes.append(name) proxy = sandbox.spec.network.proxy @@ -121,15 +127,17 @@ async def test_creates_sandbox_with_multiple_proxy_routing_rules(created_sandbox async def test_creates_sandbox_with_proxy_bypass_only(created_sandboxes): name = unique_name("proxy-bypass") - sandbox = await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": {"bypass": ["*.s3.amazonaws.com", "169.254.169.254"]}, - }, - }) + sandbox = await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": {"bypass": ["*.s3.amazonaws.com", "169.254.169.254"]}, + }, + } + ) created_sandboxes.append(name) proxy = sandbox.spec.network.proxy @@ -139,25 +147,27 @@ async def test_creates_sandbox_with_proxy_bypass_only(created_sandboxes): async def test_creates_sandbox_with_proxy_and_allowed_domains_combined(created_sandboxes): name = unique_name("proxy-fw") - sandbox = await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "allowedDomains": ["api.stripe.com", "api.openai.com", "*.s3.amazonaws.com"], - "proxy": { - "routing": [ - { - "destinations": ["api.stripe.com"], - "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, - "secrets": {"stripe-key": "sk-live-test123"}, - }, - ], - "bypass": ["*.s3.amazonaws.com"], + sandbox = await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "allowedDomains": ["api.stripe.com", "api.openai.com", "*.s3.amazonaws.com"], + "proxy": { + "routing": [ + { + "destinations": ["api.stripe.com"], + "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, + "secrets": {"stripe-key": "sk-live-test123"}, + }, + ], + "bypass": ["*.s3.amazonaws.com"], + }, }, - }, - }) + } + ) created_sandboxes.append(name) network = sandbox.spec.network diff --git a/tests/integration/core/sandbox/proxy/test_e2e.py b/tests/integration/core/sandbox/proxy/test_e2e.py index 3d061ed2..3c283c67 100644 --- a/tests/integration/core/sandbox/proxy/test_e2e.py +++ b/tests/integration/core/sandbox/proxy/test_e2e.py @@ -24,34 +24,36 @@ class TestProxyEndToEnd: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("proxy-e2e") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": { - "X-Proxy-Test": "header-injected", - "X-Api-Key": "{{SECRET:test-api-key}}", + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": { + "X-Proxy-Test": "header-injected", + "X-Api-Key": "{{SECRET:test-api-key}}", + }, + "body": { + "injected_field": "body-injected", + "secret_body": "{{SECRET:test-api-key}}", + }, + "secrets": {"test-api-key": "resolved-secret-42"}, }, - "body": { - "injected_field": "body-injected", - "secret_body": "{{SECRET:test-api-key}}", + { + "destinations": ["*.httpbin.org"], + "headers": {"X-Wildcard-Match": "wildcard-injected"}, }, - "secrets": {"test-api-key": "resolved-secret-42"}, - }, - { - "destinations": ["*.httpbin.org"], - "headers": {"X-Wildcard-Match": "wildcard-injected"}, - }, - ], + ], + }, }, - }, - }) + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -60,10 +62,12 @@ async def setup_sandbox(self, request): pass async def test_routes_https_requests_through_proxy_with_header_injection(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers.get("x-blaxel-request-id") is not None @@ -71,10 +75,12 @@ async def test_routes_https_requests_through_proxy_with_header_injection(self): assert headers["x-api-key"] == "resolved-secret-42" async def test_routes_post_requests_through_proxy_with_body_injection(self): - result = await self.sandbox.process.exec({ - "command": """node /tmp/proxy-test.js POST https://httpbin.org/post '{}' '{"user_data":"original"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """node /tmp/proxy-test.js POST https://httpbin.org/post '{}' '{"user_data":"original"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["user_data"] == "original" @@ -86,10 +92,12 @@ async def test_routes_post_requests_through_proxy_with_body_injection(self): assert response["json"]["secret_body"] == "resolved-secret-42" async def test_preserves_user_sent_headers_when_routing(self): - result = await self.sandbox.process.exec({ - "command": """node /tmp/proxy-test.js GET https://httpbin.org/headers '{"X-User-Custom":"my-value"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """node /tmp/proxy-test.js GET https://httpbin.org/headers '{"X-User-Custom":"my-value"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-user-custom"] == "my-value" @@ -97,65 +105,75 @@ async def test_preserves_user_sent_headers_when_routing(self): assert headers["x-proxy-test"] == "header-injected" async def test_does_not_route_local_requests_through_proxy(self): - result = await self.sandbox.process.exec({ - "command": ( - "node -e '" - 'const http = require("http");' - "const srv = http.createServer((req, res) => {" - 'res.writeHead(200, {"Content-Type": "application/json"});' - "res.end(JSON.stringify(req.headers));" - "});" - "srv.listen(19876, () => {" - 'http.get("http://localhost:19876", (r) => {' - 'let d = ""; r.on("data", c => d += c);' - 'r.on("end", () => { console.log(d); srv.close(); });' - "});" - "});'" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "node -e '" + 'const http = require("http");' + "const srv = http.createServer((req, res) => {" + 'res.writeHead(200, {"Content-Type": "application/json"});' + "res.end(JSON.stringify(req.headers));" + "});" + "srv.listen(19876, () => {" + 'http.get("http://localhost:19876", (r) => {' + 'let d = ""; r.on("data", c => d += c);' + 'r.on("end", () => { console.log(d); srv.close(); });' + "});" + "});'" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = parse_json_output(result.logs) assert headers.get("x-blaxel-request-id") is None assert headers.get("x-proxy-test") is None async def test_does_not_inject_headers_for_non_routed_destinations(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://www.google.com", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://www.google.com", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert len((result.logs or "").strip()) > 0 async def test_wildcard_route_matches_subdomain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://beta.httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://beta.httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-wildcard-match"] == "wildcard-injected" async def test_wildcard_route_does_not_match_bare_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers.get("x-wildcard-match") is None async def test_verifies_proxy_env_vars_are_set(self): - result = await self.sandbox.process.exec({ - "command": ( - "node -e '" - 'const vars = ["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","NODE_EXTRA_CA_CERTS","SSL_CERT_FILE"];' - "const result = {};" - 'vars.forEach(v => result[v] = process.env[v] ? "set" : "unset");' - "console.log(JSON.stringify(result));'" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "node -e '" + 'const vars = ["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","NODE_EXTRA_CA_CERTS","SSL_CERT_FILE"];' + "const result = {};" + 'vars.forEach(v => result[v] = process.env[v] ? "set" : "unset");' + "console.log(JSON.stringify(result));'" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 envs = parse_json_output(result.logs) assert envs["HTTP_PROXY"] == "set" diff --git a/tests/integration/core/sandbox/proxy/test_firewall.py b/tests/integration/core/sandbox/proxy/test_firewall.py index d4471e91..a90188ce 100644 --- a/tests/integration/core/sandbox/proxy/test_firewall.py +++ b/tests/integration/core/sandbox/proxy/test_firewall.py @@ -34,16 +34,18 @@ class TestFirewallAllowedDomains: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("fw-allow") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "allowedDomains": ["httpbin.org"], - "proxy": {"routing": []}, - }, - }) + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "allowedDomains": ["httpbin.org"], + "proxy": {"routing": []}, + }, + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -52,18 +54,22 @@ async def setup_sandbox(self, request): pass async def test_allows_requests_to_allowlisted_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert _logs_contain_host(result.logs, "httpbin.org") async def test_blocks_requests_to_non_allowlisted_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://example.com", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://example.com", + "wait_for_completion": True, + } + ) assert result.exit_code != 0 @@ -77,16 +83,18 @@ class TestFirewallForbiddenDomains: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("fw-deny") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "forbiddenDomains": ["example.com"], - "proxy": {"routing": []}, - }, - }) + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "forbiddenDomains": ["example.com"], + "proxy": {"routing": []}, + }, + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -95,18 +103,22 @@ async def setup_sandbox(self, request): pass async def test_allows_requests_to_non_forbidden_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert _logs_contain_host(result.logs, "httpbin.org") async def test_blocks_requests_to_forbidden_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://example.com", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://example.com", + "wait_for_completion": True, + } + ) assert result.exit_code != 0 @@ -120,17 +132,19 @@ class TestFirewallCombined: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("fw-combo") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "allowedDomains": ["httpbin.org", "example.com"], - "forbiddenDomains": ["example.com"], - "proxy": {"routing": []}, - }, - }) + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "allowedDomains": ["httpbin.org", "example.com"], + "forbiddenDomains": ["example.com"], + "proxy": {"routing": []}, + }, + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -139,10 +153,12 @@ async def setup_sandbox(self, request): pass async def test_allowed_domains_takes_precedence_over_forbidden_domains(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert "httpbin.org" in (result.logs or "") @@ -157,23 +173,25 @@ class TestFirewallWithProxyRouting: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("fw-proxy") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "allowedDomains": ["httpbin.org"], - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": {"X-Firewall-Test": "allowed-and-injected"}, - }, - ], + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "allowedDomains": ["httpbin.org"], + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": {"X-Firewall-Test": "allowed-and-injected"}, + }, + ], + }, }, - }, - }) + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -182,19 +200,23 @@ async def setup_sandbox(self, request): pass async def test_injects_headers_for_allowlisted_and_routed_domain(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-firewall-test"] == "allowed-and-injected" async def test_blocks_non_allowlisted_domain_even_without_routing(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://example.com", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://example.com", + "wait_for_completion": True, + } + ) assert result.exit_code != 0 @@ -209,23 +231,27 @@ class TestFirewallNoProxyBypass: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("fw-bypass") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "firewall": {"rulesets": ["proxy"]}, - "allowedDomains": ["httpbin.org"], - "proxy": {"routing": []}, - }, - }) + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "firewall": {"rulesets": ["proxy"]}, + "allowedDomains": ["httpbin.org"], + "proxy": {"routing": []}, + }, + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) # Warm up the proxy path so the first real assertion isn't racing setup. - await request.cls.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", - "wait_for_completion": True, - }) + await request.cls.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/get", + "wait_for_completion": True, + } + ) yield try: await SandboxInstance.delete(request.cls.sandbox_name) @@ -239,11 +265,13 @@ async def test_blocks_requests_even_when_proxy_env_vars_are_unset(self): # won't be refused -- it just hangs. `timeout` turns that hang into a # non-zero exit (124), proving the bypass is blocked rather than silently # succeeding. - result = await self.sandbox.process.exec({ - "command": ( - "timeout 10 env -u HTTP_PROXY -u http_proxy -u HTTPS_PROXY " - "-u https_proxy node /tmp/proxy-test.js GET https://httpbin.org/get" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "timeout 10 env -u HTTP_PROXY -u http_proxy -u HTTPS_PROXY " + "-u https_proxy node /tmp/proxy-test.js GET https://httpbin.org/get" + ), + "wait_for_completion": True, + } + ) assert result.exit_code != 0, result.logs diff --git a/tests/integration/core/sandbox/proxy/test_get_delete.py b/tests/integration/core/sandbox/proxy/test_get_delete.py index 4664d67f..d820f289 100644 --- a/tests/integration/core/sandbox/proxy/test_get_delete.py +++ b/tests/integration/core/sandbox/proxy/test_get_delete.py @@ -17,27 +17,29 @@ async def test_retrieves_sandbox_with_proxy_and_validates_config(created_sandboxes): name = unique_name("proxy-get") - await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["api.openai.com"], - "headers": { - "Authorization": "Bearer {{SECRET:openai-key}}", - "OpenAI-Organization": "org-abc123", + await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["api.openai.com"], + "headers": { + "Authorization": "Bearer {{SECRET:openai-key}}", + "OpenAI-Organization": "org-abc123", + }, + "secrets": {"openai-key": "sk-proj-test789"}, }, - "secrets": {"openai-key": "sk-proj-test789"}, - }, - ], - "bypass": ["169.254.169.254"], + ], + "bypass": ["169.254.169.254"], + }, }, - }, - }) + } + ) created_sandboxes.append(name) retrieved = await SandboxInstance.get(name) @@ -55,12 +57,14 @@ async def test_retrieves_sandbox_with_proxy_and_validates_config(created_sandbox async def test_returns_no_proxy_config_when_sandbox_has_none(created_sandboxes): name = unique_name("proxy-none") - await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - }) + await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + } + ) created_sandboxes.append(name) retrieved = await SandboxInstance.get(name) @@ -71,23 +75,25 @@ async def test_returns_no_proxy_config_when_sandbox_has_none(created_sandboxes): async def test_deletes_sandbox_with_proxy_configuration(): name = unique_name("proxy-del") - await SandboxInstance.create({ - "name": name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["api.stripe.com"], - "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, - "secrets": {"stripe-key": "sk-live-test123"}, - }, - ], + await SandboxInstance.create( + { + "name": name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["api.stripe.com"], + "headers": {"Authorization": "Bearer {{SECRET:stripe-key}}"}, + "secrets": {"stripe-key": "sk-live-test123"}, + }, + ], + }, }, - }, - }) + } + ) await SandboxInstance.delete(name) deleted = await wait_for_sandbox_deletion(name, max_attempts=60) diff --git a/tests/integration/core/sandbox/proxy/test_python.py b/tests/integration/core/sandbox/proxy/test_python.py index 80201414..e0aa229c 100644 --- a/tests/integration/core/sandbox/proxy/test_python.py +++ b/tests/integration/core/sandbox/proxy/test_python.py @@ -24,37 +24,41 @@ class TestProxyPythonRequests: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("proxy-py") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": "blaxel/py-app:latest", - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": { - "X-Proxy-Test": "header-injected", - "X-Api-Key": "{{SECRET:test-api-key}}", + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": "blaxel/py-app:latest", + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": { + "X-Proxy-Test": "header-injected", + "X-Api-Key": "{{SECRET:test-api-key}}", + }, + "body": { + "injected_field": "body-injected", + "secret_body": "{{SECRET:test-api-key}}", + }, + "secrets": {"test-api-key": "resolved-secret-42"}, }, - "body": { - "injected_field": "body-injected", - "secret_body": "{{SECRET:test-api-key}}", - }, - "secrets": {"test-api-key": "resolved-secret-42"}, - }, - ], + ], + }, }, - }, - }) + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.py", PYTHON_HELPER_SCRIPT) - pip_result = await request.cls.sandbox.process.exec({ - "command": "pip install --break-system-packages requests 2>&1", - "wait_for_completion": True, - }) + pip_result = await request.cls.sandbox.process.exec( + { + "command": "pip install --break-system-packages requests 2>&1", + "wait_for_completion": True, + } + ) if pip_result.exit_code != 0: raise RuntimeError(f"pip install failed: {(pip_result.logs or '')[:500]}") @@ -65,10 +69,12 @@ async def setup_sandbox(self, request): pass async def test_python_requests_get_with_header_injection(self): - result = await self.sandbox.process.exec({ - "command": "python3 /tmp/proxy-test.py GET https://httpbin.org/headers 2>&1", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "python3 /tmp/proxy-test.py GET https://httpbin.org/headers 2>&1", + "wait_for_completion": True, + } + ) if result.exit_code != 0: raise RuntimeError(f"python3 exited {result.exit_code}: {(result.logs or '')[:1500]}") headers = lowercase_keys(parse_json_output(result.logs)["headers"]) @@ -77,10 +83,12 @@ async def test_python_requests_get_with_header_injection(self): assert headers["x-api-key"] == "resolved-secret-42" async def test_python_requests_post_with_body_injection(self): - result = await self.sandbox.process.exec({ - "command": """python3 /tmp/proxy-test.py POST https://httpbin.org/post '{}' '{"user_data":"from-python"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """python3 /tmp/proxy-test.py POST https://httpbin.org/post '{}' '{"user_data":"from-python"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["user_data"] == "from-python" @@ -88,10 +96,12 @@ async def test_python_requests_post_with_body_injection(self): assert response["json"]["secret_body"] == "resolved-secret-42" async def test_python_requests_preserves_user_headers(self): - result = await self.sandbox.process.exec({ - "command": """python3 /tmp/proxy-test.py GET https://httpbin.org/headers '{"X-User-Custom":"from-python"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """python3 /tmp/proxy-test.py GET https://httpbin.org/headers '{"X-User-Custom":"from-python"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-user-custom"] == "from-python" diff --git a/tests/integration/core/sandbox/proxy/test_secrets.py b/tests/integration/core/sandbox/proxy/test_secrets.py index 12887f36..f75378a3 100644 --- a/tests/integration/core/sandbox/proxy/test_secrets.py +++ b/tests/integration/core/sandbox/proxy/test_secrets.py @@ -24,40 +24,42 @@ class TestSecretsReplacementValidation: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("proxy-sec") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["httpbin.org"], - "headers": { - "X-Token": "Bearer {{SECRET:api-token}}", - "X-Multi": "{{SECRET:part-a}}-{{SECRET:part-b}}", - "X-Plain": "no-secret-here", + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["httpbin.org"], + "headers": { + "X-Token": "Bearer {{SECRET:api-token}}", + "X-Multi": "{{SECRET:part-a}}-{{SECRET:part-b}}", + "X-Plain": "no-secret-here", + }, + "body": { + "secret_key": "{{SECRET:api-token}}", + "composite": "prefix-{{SECRET:part-a}}-suffix", + }, + "secrets": { + "api-token": "tok_live_abc123", + "part-a": "ALPHA", + "part-b": "BETA", + }, }, - "body": { - "secret_key": "{{SECRET:api-token}}", - "composite": "prefix-{{SECRET:part-a}}-suffix", + { + "destinations": ["*.example.com"], + "headers": {"X-Other-Secret": "{{SECRET:other-key}}"}, + "secrets": {"other-key": "other-value-999"}, }, - "secrets": { - "api-token": "tok_live_abc123", - "part-a": "ALPHA", - "part-b": "BETA", - }, - }, - { - "destinations": ["*.example.com"], - "headers": {"X-Other-Secret": "{{SECRET:other-key}}"}, - "secrets": {"other-key": "other-value-999"}, - }, - ], + ], + }, }, - }, - }) + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -66,29 +68,35 @@ async def setup_sandbox(self, request): pass async def test_resolves_secret_in_headers_to_actual_value(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-token"] == "Bearer tok_live_abc123" assert headers["x-plain"] == "no-secret-here" async def test_resolves_multiple_secret_placeholders_in_single_header(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-multi"] == "ALPHA-BETA" async def test_resolves_secret_in_post_body_fields(self): - result = await self.sandbox.process.exec({ - "command": """node /tmp/proxy-test.js POST https://httpbin.org/post '{}' '{"user_field":"untouched"}'""", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": """node /tmp/proxy-test.js POST https://httpbin.org/post '{}' '{"user_field":"untouched"}'""", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["user_field"] == "untouched" @@ -96,56 +104,66 @@ async def test_resolves_secret_in_post_body_fields(self): assert response["json"]["composite"] == "prefix-ALPHA-suffix" async def test_does_not_leak_secrets_from_one_route_to_another(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers.get("x-other-secret") is None async def test_does_not_expose_raw_secret_template_on_the_wire(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 assert "{{SECRET:" not in (result.logs or "") async def test_resolves_secret_in_user_sent_headers(self): - result = await self.sandbox.process.exec({ - "command": ( - "node /tmp/proxy-test.js GET https://httpbin.org/headers " - """'{"X-User-Token":"{{SECRET:api-token}}","X-User-Combo":"pre-{{SECRET:part-a}}-post"}'""" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "node /tmp/proxy-test.js GET https://httpbin.org/headers " + """'{"X-User-Token":"{{SECRET:api-token}}","X-User-Combo":"pre-{{SECRET:part-a}}-post"}'""" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-user-token"] == "tok_live_abc123" assert headers["x-user-combo"] == "pre-ALPHA-post" async def test_resolves_secret_in_user_sent_post_body(self): - result = await self.sandbox.process.exec({ - "command": ( - "node /tmp/proxy-test.js POST https://httpbin.org/post " - """'{}' '{"api_key":"{{SECRET:api-token}}","mixed":"hello-{{SECRET:part-b}}-world"}'""" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "node /tmp/proxy-test.js POST https://httpbin.org/post " + """'{}' '{"api_key":"{{SECRET:api-token}}","mixed":"hello-{{SECRET:part-b}}-world"}'""" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 response = parse_json_output(result.logs) assert response["json"]["api_key"] == "tok_live_abc123" assert response["json"]["mixed"] == "hello-BETA-world" async def test_does_not_resolve_secrets_from_different_route_in_user_headers(self): - result = await self.sandbox.process.exec({ - "command": ( - "node /tmp/proxy-test.js GET https://httpbin.org/headers " - """'{"X-Wrong-Route":"{{SECRET:other-key}}"}'""" - ), - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": ( + "node /tmp/proxy-test.js GET https://httpbin.org/headers " + """'{"X-Wrong-Route":"{{SECRET:other-key}}"}'""" + ), + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-wrong-route"] == "{{SECRET:other-key}}" diff --git a/tests/integration/core/sandbox/proxy/test_wildcard.py b/tests/integration/core/sandbox/proxy/test_wildcard.py index bd72dce4..261a1a3e 100644 --- a/tests/integration/core/sandbox/proxy/test_wildcard.py +++ b/tests/integration/core/sandbox/proxy/test_wildcard.py @@ -24,23 +24,25 @@ class TestProxyWildcardDestination: @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") async def setup_sandbox(self, request): request.cls.sandbox_name = unique_name("proxy-wild") - request.cls.sandbox = await SandboxInstance.create({ - "name": request.cls.sandbox_name, - "image": default_image, - "region": default_region, - "labels": default_labels, - "network": { - "proxy": { - "routing": [ - { - "destinations": ["*"], - "headers": {"X-Global-Auth": "Bearer {{SECRET:global-key}}"}, - "secrets": {"global-key": "global-token-xyz"}, - }, - ], + request.cls.sandbox = await SandboxInstance.create( + { + "name": request.cls.sandbox_name, + "image": default_image, + "region": default_region, + "labels": default_labels, + "network": { + "proxy": { + "routing": [ + { + "destinations": ["*"], + "headers": {"X-Global-Auth": "Bearer {{SECRET:global-key}}"}, + "secrets": {"global-key": "global-token-xyz"}, + }, + ], + }, }, - }, - }) + } + ) await request.cls.sandbox.fs.write("/tmp/proxy-test.js", PROXY_HELPER_SCRIPT) yield try: @@ -49,10 +51,12 @@ async def setup_sandbox(self, request): pass async def test_applies_global_rule_to_httpbin(self): - result = await self.sandbox.process.exec({ - "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", - "wait_for_completion": True, - }) + result = await self.sandbox.process.exec( + { + "command": "node /tmp/proxy-test.js GET https://httpbin.org/headers", + "wait_for_completion": True, + } + ) assert result.exit_code == 0 headers = lowercase_keys(parse_json_output(result.logs)["headers"]) assert headers["x-global-auth"] == "Bearer global-token-xyz" diff --git a/tests/integration/livekit/test_model.py b/tests/integration/livekit/test_model.py index c59c2917..a42ac4fc 100644 --- a/tests/integration/livekit/test_model.py +++ b/tests/integration/livekit/test_model.py @@ -29,7 +29,9 @@ async def _created_livekit_kwargs(provider_type: str, model: str, **kwargs): patch("blaxel.livekit.model.AsyncOpenAI", return_value=openai_client), patch("blaxel.livekit.model.openai.LLM", return_value=llm) as llm_factory, ): - result = await get_livekit_model("https://example.com/models/test", provider_type, model, **kwargs) + result = await get_livekit_model( + "https://example.com/models/test", provider_type, model, **kwargs + ) assert result is llm return llm_factory.call_args.kwargs From 41f710697511865f700ed985e3fdebc729498759 Mon Sep 17 00:00:00 2001 From: cdrappier Date: Wed, 24 Jun 2026 20:09:32 +0000 Subject: [PATCH 2/5] fix: restore with_headers merge behavior after regeneration Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/blaxel/core/client/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/blaxel/core/client/client.py b/src/blaxel/core/client/client.py index 00f5eb33..4a5bd452 100644 --- a/src/blaxel/core/client/client.py +++ b/src/blaxel/core/client/client.py @@ -64,12 +64,13 @@ def with_base_url(self, base_url: str) -> "Client": def with_headers(self, headers: dict[str, str]) -> "Client": """Get a new client matching this one with additional headers""" - self._headers = headers + merged_headers = {**self._headers, **headers} + self._headers = merged_headers if self._client is not None: self._client.headers.update(headers) if self._async_client is not None: self._async_client.headers.update(headers) - return evolve(self, headers={**self._headers, **headers}) + return evolve(self, headers=merged_headers) def with_cookies(self, cookies: dict[str, str]) -> "Client": """Get a new client matching this one with additional cookies""" From af3182c5acb058d5610c2dbc3f95433586dba1ea Mon Sep 17 00:00:00 2001 From: cdrappier Date: Wed, 24 Jun 2026 20:30:39 +0000 Subject: [PATCH 3/5] test: add manual drive ACL test for per-drive permissions (ENG-2761) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/manual/__init__.py | 0 tests/manual/drive_acl.py | 679 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 tests/manual/__init__.py create mode 100644 tests/manual/drive_acl.py diff --git a/tests/manual/__init__.py b/tests/manual/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/manual/drive_acl.py b/tests/manual/drive_acl.py new file mode 100644 index 00000000..6c1b3af3 --- /dev/null +++ b/tests/manual/drive_acl.py @@ -0,0 +1,679 @@ +""" +Manual test for per-drive ACL enforcement (ENG-2761). + +Prerequisites (all three PRs must be deployed): + - controlplane#4582 -- DrivePermission model + ACL sync to filer + - seaweedfs#27 -- filer-side ACL enforcement (domain-aware: blaxel.dev / blaxel.ai) + - executionplane#171 -- workload labels in JWT token + +Environment variables: + BL_WORKSPACE -- workspace name + BL_API_KEY -- API key with drive + sandbox permissions + BL_ENV -- "dev" or "prod" (default: "dev") + BL_DRIVE_REGION -- drive region override (default: eu-dub-1 for dev, us-pdx-1 for prod) + +Usage: + uv run python tests/manual/drive_acl.py + uv run python tests/manual/drive_acl.py --scenario open-access + uv run python tests/manual/drive_acl.py --scenario label-match +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import sys +import uuid +from dataclasses import dataclass +from typing import Any + +import httpx + +from blaxel.core.client.models import Drive, DriveSpec, Metadata, MetadataLabels +from blaxel.core.client.models.drive_permission import DrivePermission +from blaxel.core.client.models.drive_permission_labels import DrivePermissionLabels +from blaxel.core.client.models.drive_permission_mode import DrivePermissionMode +from blaxel.core.client.types import UNSET +from blaxel.core.common.settings import settings +from blaxel.core.drive import DriveInstance +from blaxel.core.sandbox import SandboxInstance + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +ENV = os.environ.get("BL_ENV", "dev") +REGION = os.environ.get("BL_DRIVE_REGION", "eu-dub-1" if ENV == "dev" else "us-pdx-1") +IMAGE = "blaxel/base-image:latest" +TEST_LABELS = {"env": "manual-test", "created-by": "drive-acl-test"} +EXEC_TIMEOUT_S = 30 +MOUNT_SETTLE_S = 3 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def uid(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def async_sleep(seconds: float) -> None: + await asyncio.sleep(seconds) + + +async def create_drive_with_permissions( + name: str, + permissions: list[dict[str, Any]], +) -> DriveInstance: + perm_objects = [] + for p in permissions: + raw_labels = p.get("labels", {}) + if raw_labels: + labels = DrivePermissionLabels.from_dict(raw_labels) + else: + # Empty dict is falsy, from_dict returns None; construct manually + labels = DrivePermissionLabels() + mode = DrivePermissionMode(p.get("mode", "read-write")) + path = p.get("path", UNSET) + perm_objects.append(DrivePermission(labels=labels, mode=mode, path=path)) + + drive = Drive( + metadata=Metadata( + name=name, + labels=MetadataLabels.from_dict(TEST_LABELS), + ), + spec=DriveSpec( + region=REGION, + size=1, + permissions=perm_objects, + ), + ) + return await DriveInstance.create(drive) + + +async def update_drive_permissions( + drive_name: str, + permissions: list[dict[str, Any]], +) -> None: + await settings.authenticate() + auth_headers = settings.headers + base_url = settings.base_url + url = f"{base_url}/drives/{drive_name}" + + perm_dicts = [] + for p in permissions: + perm_dicts.append( + { + "labels": p.get("labels", {}), + "mode": p.get("mode", "read-write"), + } + ) + + async with httpx.AsyncClient() as http_client: + res = await http_client.put( + url, + headers={"Content-Type": "application/json", **auth_headers}, + json={"metadata": {}, "spec": {"permissions": perm_dicts}}, + ) + if res.status_code >= 400: + raise Exception(f"Failed to update drive permissions: {res.status_code} {res.text}") + + +async def create_sandbox( + name: str, + labels: dict[str, str], +) -> SandboxInstance: + return await SandboxInstance.create( + { + "name": name, + "image": IMAGE, + "memory": 2048, + "region": REGION, + "labels": {**TEST_LABELS, **labels}, + }, + safe=True, + ) + + +async def exec_in_sandbox( + sbx: SandboxInstance, + command: str, +) -> tuple[bool, str]: + try: + result = await asyncio.wait_for( + sbx.process.exec({"command": command, "wait_for_completion": True}), + timeout=EXEC_TIMEOUT_S, + ) + return (True, result.logs or "") + except Exception as err: + return (False, str(err)) + + +# --------------------------------------------------------------------------- +# Cleanup tracker +# --------------------------------------------------------------------------- + +cleanup_sandboxes: list[str] = [] +cleanup_drives: list[str] = [] + + +async def cleanup() -> None: + print("\n--- Cleanup ---") + for name in cleanup_sandboxes: + try: + await SandboxInstance.delete(name) + print(f" deleted sandbox {name}") + except Exception: + pass + await async_sleep(5) + for name in cleanup_drives: + try: + await DriveInstance.delete(name) + print(f" deleted drive {name}") + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Result tracking +# --------------------------------------------------------------------------- + + +@dataclass +class TestResult: + name: str + passed: bool + detail: str + skipped: bool = False + + +results: list[TestResult] = [] + + +def record(name: str, passed: bool, detail: str) -> None: + icon = "PASS" if passed else "FAIL" + print(f" [{icon}] {name}: {detail}") + results.append(TestResult(name=name, passed=passed, detail=detail)) + + +def skip(name: str, reason: str) -> None: + print(f" [SKIP] {name}: {reason}") + results.append(TestResult(name=name, passed=True, detail=reason, skipped=True)) + + +def format_error(err: Exception) -> str: + return str(err) + + +async def debug_jwt(sbx: SandboxInstance) -> dict[str, Any] | None: + domain = "blaxel.dev" if ENV == "dev" else "blaxel.ai" + token_path = f"/var/run/secrets/{domain}/identity/token" + ok, logs = await exec_in_sandbox(sbx, f"cat {token_path}") + if not ok or not logs.strip(): + print(f" [DEBUG] Could not read JWT from {token_path}: {logs}") + return None + try: + parts = logs.strip().split(".") + if len(parts) != 3: + return None + payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) + payload_str = json.dumps(payload, indent=2) + print(f" [DEBUG] JWT claims: {chr(10).join(payload_str.split(chr(10))[:15])}...") + return payload + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- + + +async def scenario_open_access() -> None: + """Drive with NO permissions (empty array) -- any sandbox can access.""" + print("\n=== Scenario: open-access (no permissions = allow all) ===") + drive_name = uid("acl-open") + sbx_name = uid("acl-open-sbx") + + await create_drive_with_permissions(drive_name, []) + cleanup_drives.append(drive_name) + + sbx = await create_sandbox(sbx_name, {"role": "anything"}) + cleanup_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/open") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx, "echo 'open-access-ok' > /mnt/open/test.txt") + record("open-access write", ok, "wrote successfully" if ok else logs) + + ok, logs = await exec_in_sandbox(sbx, "cat /mnt/open/test.txt") + record("open-access read", ok and "open-access-ok" in logs, logs.strip()) + + +async def scenario_label_match() -> None: + """Drive with label-based permission -- matching sandbox gets access.""" + print("\n=== Scenario: label-match (sandbox has matching labels) ===") + drive_name = uid("acl-match") + sbx_name = uid("acl-match-sbx") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "backend", "project": "acl-test"}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + sbx = await create_sandbox(sbx_name, {"team": "backend", "project": "acl-test"}) + cleanup_sandboxes.append(sbx_name) + + await debug_jwt(sbx) + + await sbx.drives.mount(drive_name, "/mnt/match") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx, "echo 'label-match-ok' > /mnt/match/test.txt") + record("label-match write", ok, "wrote successfully" if ok else logs) + + ok, logs = await exec_in_sandbox(sbx, "cat /mnt/match/test.txt") + record("label-match read", ok and "label-match-ok" in logs, logs.strip()) + + +async def scenario_label_mismatch() -> None: + """Drive with label-based permission -- non-matching sandbox is denied.""" + print("\n=== Scenario: label-mismatch (sandbox lacks required labels) ===") + drive_name = uid("acl-mis") + sbx_name = uid("acl-mis-sbx") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "secret-team"}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + sbx = await create_sandbox(sbx_name, {"team": "other"}) + cleanup_sandboxes.append(sbx_name) + + try: + await sbx.drives.mount(drive_name, "/mnt/mis") + await async_sleep(MOUNT_SETTLE_S) + ok, logs = await exec_in_sandbox(sbx, "echo 'should-fail' > /mnt/mis/test.txt") + record( + "label-mismatch mount+write denied", + not ok, + f"unexpected success: {logs.strip()}" if ok else "write denied at file level", + ) + except Exception as err: + msg = format_error(err) + is_acl_denial = any( + s in msg + for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") + ) + record( + "label-mismatch mount denied", + is_acl_denial, + "mount correctly denied by ACL" if is_acl_denial else f"unexpected error: {msg}", + ) + + +async def scenario_read_only() -> None: + """Read-only mode: matching sandbox can read but NOT write.""" + print("\n=== Scenario: read-only (mode=read blocks writes) ===") + drive_name = uid("acl-ro") + writer_name = uid("acl-ro-writer") + reader_name = uid("acl-ro-reader") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"role": "reader"}, "mode": "read"}, + {"labels": {"role": "writer"}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + writer = await create_sandbox(writer_name, {"role": "writer"}) + cleanup_sandboxes.append(writer_name) + await writer.drives.mount(drive_name, "/mnt/ro") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(writer, "echo 'read-only-test-data' > /mnt/ro/readonly.txt") + record("read-only seed write", ok, "seeded data" if ok else logs) + + reader = await create_sandbox(reader_name, {"role": "reader"}) + cleanup_sandboxes.append(reader_name) + try: + await reader.drives.mount(drive_name, "/mnt/ro", read_only=True) + except Exception as err: + msg = format_error(err) + record("read-only mount", False, f"mount failed: {msg}") + return + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(reader, "cat /mnt/ro/readonly.txt") + record("read-only read succeeds", ok and "read-only-test-data" in logs, logs.strip()) + + ok, logs = await exec_in_sandbox(reader, "echo 'should-fail' > /mnt/ro/illegal.txt") + record( + "read-only write denied", + not ok or any(s in logs for s in ("denied", "Read-only", "Permission", "error")), + f"unexpected success: {logs.strip()}" if ok else "write denied as expected", + ) + + +async def scenario_multiple_permissions_or() -> None: + """Two permissions with OR logic -- second rule match grants access.""" + print("\n=== Scenario: multiple-permissions-or (first match wins) ===") + drive_name = uid("acl-or") + sbx_name = uid("acl-or-sbx") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "alpha"}, "mode": "read-write"}, + {"labels": {"team": "beta"}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + sbx = await create_sandbox(sbx_name, {"team": "beta"}) + cleanup_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/or") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx, "echo 'or-logic-ok' > /mnt/or/test.txt") + record("or-logic write", ok, "wrote successfully" if ok else logs) + + ok, logs = await exec_in_sandbox(sbx, "cat /mnt/or/test.txt") + record("or-logic read", ok and "or-logic-ok" in logs, logs.strip()) + + +async def scenario_and_logic() -> None: + """AND logic within a single permission -- all labels must match.""" + print("\n=== Scenario: and-logic (all labels must match within a permission) ===") + drive_name = uid("acl-and") + sbx_partial_name = uid("acl-and-partial") + sbx_full_name = uid("acl-and-full") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "core", "tier": "staging"}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + # Partial match: has team=core but NOT tier=staging -- should be denied + partial = await create_sandbox(sbx_partial_name, {"team": "core"}) + cleanup_sandboxes.append(sbx_partial_name) + try: + await partial.drives.mount(drive_name, "/mnt/and") + await async_sleep(MOUNT_SETTLE_S) + ok, logs = await exec_in_sandbox(partial, "echo 'should-fail' > /mnt/and/test.txt") + record( + "and-logic partial denied", + not ok, + f"unexpected success: {logs.strip()}" if ok else "denied at file level", + ) + except Exception as err: + msg = format_error(err) + is_acl_denial = any( + s in msg + for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") + ) + record( + "and-logic partial denied", + is_acl_denial, + "mount correctly denied (partial label match)" + if is_acl_denial + else f"unexpected error: {msg}", + ) + + # Full match: has both team=core AND tier=staging -- should succeed + full = await create_sandbox(sbx_full_name, {"team": "core", "tier": "staging"}) + cleanup_sandboxes.append(sbx_full_name) + await full.drives.mount(drive_name, "/mnt/and") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(full, "echo 'and-logic-ok' > /mnt/and/test.txt") + record("and-logic full-match write", ok, "wrote successfully" if ok else logs) + + +async def scenario_path_scoping() -> None: + """Permission restricts access to /data/ subfolder.""" + print("\n=== Scenario: path-scoping (permission restricts to subfolder) ===") + drive_name = uid("acl-path") + writer_name = uid("acl-path-writer") + scoped_name = uid("acl-path-scoped") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {"role": "admin"}, "mode": "read-write", "path": "/"}, + {"labels": {"role": "scoped"}, "mode": "read-write", "path": "/data"}, + ], + ) + cleanup_drives.append(drive_name) + + # Admin sandbox: seed data in both root and /data/ + admin = await create_sandbox(writer_name, {"role": "admin"}) + cleanup_sandboxes.append(writer_name) + await admin.drives.mount(drive_name, "/mnt/path") + await async_sleep(MOUNT_SETTLE_S) + + await exec_in_sandbox(admin, "echo 'root-secret' > /mnt/path/secret.txt") + await exec_in_sandbox( + admin, "mkdir -p /mnt/path/data && echo 'data-ok' > /mnt/path/data/file.txt" + ) + + # Scoped sandbox: mount with drive_path="/data" (only has access to /data) + scoped = await create_sandbox(scoped_name, {"role": "scoped"}) + cleanup_sandboxes.append(scoped_name) + await scoped.drives.mount(drive_name, "/mnt/scoped", drive_path="/data") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(scoped, "cat /mnt/scoped/file.txt") + record("path-scoping /data read", ok and "data-ok" in logs, logs.strip()) + + ok, logs = await exec_in_sandbox(scoped, "cat /mnt/scoped/secret.txt") + record( + "path-scoping root not visible", + not ok or "No such file" in logs, + f"unexpected: {logs.strip()}" if ok else "root files not visible as expected", + ) + + +async def scenario_update_permissions() -> None: + """Update permissions on an existing drive, verify enforcement changes.""" + print("\n=== Scenario: update-permissions (edit permissions on existing drive) ===") + drive_name = uid("acl-upd") + sbx_open_name = uid("acl-upd-open") + sbx_denied_name = uid("acl-upd-denied") + sbx_allowed_name = uid("acl-upd-allowed") + + # Step 1: Create drive with NO permissions (open access) + await create_drive_with_permissions(drive_name, []) + cleanup_drives.append(drive_name) + + sbx_open = await create_sandbox(sbx_open_name, {"role": "tester"}) + cleanup_sandboxes.append(sbx_open_name) + await sbx_open.drives.mount(drive_name, "/mnt/upd") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx_open, "echo 'before-update' > /mnt/upd/test.txt") + record("update-permissions open write", ok, "wrote before restriction" if ok else logs) + + # Step 2: Update drive to restrict permissions + await update_drive_permissions( + drive_name, + [ + {"labels": {"team": "restricted"}, "mode": "read-write"}, + ], + ) + record("update-permissions API call", True, "permissions updated successfully") + + # Step 3: Verify the update persisted + updated = await DriveInstance.get(drive_name) + perms = updated.spec.permissions if updated.spec else None + has_perms = ( + perms is not None + and len(perms) == 1 + and hasattr(perms[0], "labels") + and perms[0].labels is not None + and perms[0].labels.additional_properties.get("team") == "restricted" + ) + record( + "update-permissions persisted", + has_perms, + "permissions correctly saved" if has_perms else f"got: {perms}", + ) + + # Step 4: New sandbox WITHOUT the label should be denied + sbx_denied = await create_sandbox(sbx_denied_name, {"team": "other"}) + cleanup_sandboxes.append(sbx_denied_name) + try: + await sbx_denied.drives.mount(drive_name, "/mnt/upd") + await async_sleep(MOUNT_SETTLE_S) + ok, logs = await exec_in_sandbox(sbx_denied, "echo 'should-fail' > /mnt/upd/test.txt") + record( + "update-permissions denied after update", + not ok, + f"unexpected success: {logs.strip()}" if ok else "write denied at file level", + ) + except Exception as err: + msg = format_error(err) + is_acl_denial = any( + s in msg + for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") + ) + record( + "update-permissions denied after update", + is_acl_denial, + "mount correctly denied after permission update" + if is_acl_denial + else f"unexpected error: {msg}", + ) + + # Step 5: New sandbox WITH the label should succeed + sbx_allowed = await create_sandbox(sbx_allowed_name, {"team": "restricted"}) + cleanup_sandboxes.append(sbx_allowed_name) + await sbx_allowed.drives.mount(drive_name, "/mnt/upd") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx_allowed, "echo 'after-update-ok' > /mnt/upd/test2.txt") + record( + "update-permissions allowed after update", ok, "wrote with correct labels" if ok else logs + ) + + ok, logs = await exec_in_sandbox(sbx_allowed, "cat /mnt/upd/test2.txt") + record("update-permissions allowed read", ok and "after-update-ok" in logs, logs.strip()) + + +async def scenario_wildcard_permission() -> None: + """Wildcard permission (empty labels = match all workloads).""" + print("\n=== Scenario: wildcard-permission (empty labels match all) ===") + drive_name = uid("acl-wild") + sbx_name = uid("acl-wild-sbx") + + await create_drive_with_permissions( + drive_name, + [ + {"labels": {}, "mode": "read-write"}, + ], + ) + cleanup_drives.append(drive_name) + + sbx = await create_sandbox(sbx_name, {"random": "anything"}) + cleanup_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/wild") + await async_sleep(MOUNT_SETTLE_S) + + ok, logs = await exec_in_sandbox(sbx, "echo 'wildcard-ok' > /mnt/wild/test.txt") + record("wildcard-permission write", ok, "wrote successfully" if ok else logs) + + ok, logs = await exec_in_sandbox(sbx, "cat /mnt/wild/test.txt") + record("wildcard-permission read", ok and "wildcard-ok" in logs, logs.strip()) + + +# --------------------------------------------------------------------------- +# Scenario registry +# --------------------------------------------------------------------------- + +SCENARIOS: dict[str, Any] = { + "open-access": scenario_open_access, + "label-match": scenario_label_match, + "label-mismatch": scenario_label_mismatch, + "read-only": scenario_read_only, + "multiple-permissions-or": scenario_multiple_permissions_or, + "and-logic": scenario_and_logic, + "path-scoping": scenario_path_scoping, + "update-permissions": scenario_update_permissions, + "wildcard-permission": scenario_wildcard_permission, +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +async def main() -> None: + args = sys.argv[1:] + selected_scenario: str | None = None + if "--scenario" in args: + idx = args.index("--scenario") + if idx + 1 < len(args): + selected_scenario = args[idx + 1] + + print("Drive ACL Manual Test (ENG-2761)") + print(f" env={ENV} region={REGION}") + print(f" scenarios={selected_scenario or 'all'}") + + if selected_scenario and selected_scenario not in SCENARIOS: + print(f"Unknown scenario: {selected_scenario}") + print(f"Available: {', '.join(SCENARIOS.keys())}") + sys.exit(1) + + to_run = {selected_scenario: SCENARIOS[selected_scenario]} if selected_scenario else SCENARIOS + + try: + for name, fn in to_run.items(): + try: + await fn() + except Exception as err: + record(f"{name} (scenario error)", False, format_error(err)) + finally: + await cleanup() + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + + passed = [r for r in results if r.passed and not r.skipped] + skipped = [r for r in results if r.skipped] + failed = [r for r in results if not r.passed] + + print(f" Total: {len(results)}") + print(f" Passed: {len(passed)}") + print(f" Skipped: {len(skipped)}") + print(f" Failed: {len(failed)}") + + if failed: + print("\nFailed checks:") + for f in failed: + print(f" - {f.name}: {f.detail}") + + print() + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + asyncio.run(main()) From b2cdf92944efeb4069c915764a43dd70d0fc687d Mon Sep 17 00:00:00 2001 From: cdrappier Date: Wed, 24 Jun 2026 20:38:45 +0000 Subject: [PATCH 4/5] test: move drive ACL test to integration tests (ENG-2761) Replace manual test with proper pytest integration test in tests/integration/core/sandbox/test_drive_acl.py following the existing TestDriveOperations pattern. Covers all 9 ACL scenarios: open-access, label-match, label-mismatch, read-only, OR logic, AND logic, path scoping, update permissions, and wildcard permission. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../core/sandbox/test_drive_acl.py | 482 +++++++++++++ tests/manual/__init__.py | 0 tests/manual/drive_acl.py | 679 ------------------ 3 files changed, 482 insertions(+), 679 deletions(-) create mode 100644 tests/integration/core/sandbox/test_drive_acl.py delete mode 100644 tests/manual/__init__.py delete mode 100644 tests/manual/drive_acl.py diff --git a/tests/integration/core/sandbox/test_drive_acl.py b/tests/integration/core/sandbox/test_drive_acl.py new file mode 100644 index 00000000..fbeb2623 --- /dev/null +++ b/tests/integration/core/sandbox/test_drive_acl.py @@ -0,0 +1,482 @@ +"""Integration tests for per-drive ACL enforcement (ENG-2761). + +Exercises the full DrivePermission enforcement matrix: + - open-access (no permissions = allow all) + - label-match / label-mismatch + - read-only mode + - multiple permissions (OR logic) + - AND logic within a single permission + - path scoping + - update permissions on existing drive + - wildcard permission (empty labels = match all) + +Prerequisites: all ENG-2761 PRs deployed (controlplane#4582, seaweedfs#27, +executionplane#171). +""" + +import asyncio +import os + +import httpx +import pytest +import pytest_asyncio + +from blaxel.core.client.models import Drive, DriveSpec, Metadata, MetadataLabels +from blaxel.core.client.models.drive_permission import DrivePermission +from blaxel.core.client.models.drive_permission_labels import DrivePermissionLabels +from blaxel.core.client.models.drive_permission_mode import DrivePermissionMode +from blaxel.core.client.types import UNSET +from blaxel.core.common.settings import settings +from blaxel.core.drive import DriveInstance +from blaxel.core.sandbox import SandboxInstance +from tests.helpers import ( + default_image, + default_labels, + unique_name, + wait_for_sandbox_deletion, +) + +default_region = "eu-dub-1" if os.environ.get("BL_ENV") == "dev" else "us-was-1" + +MOUNT_SETTLE_S = 3 + + +def _make_permissions(perms: list[dict]) -> list[DrivePermission]: + result = [] + for p in perms: + raw_labels = p.get("labels", {}) + if raw_labels: + labels = DrivePermissionLabels.from_dict(raw_labels) + else: + labels = DrivePermissionLabels() + mode = DrivePermissionMode(p.get("mode", "read-write")) + path = p.get("path", UNSET) + result.append(DrivePermission(labels=labels, mode=mode, path=path)) + return result + + +async def _create_drive_with_permissions(name: str, permissions: list[dict]) -> DriveInstance: + drive = Drive( + metadata=Metadata( + name=name, + labels=MetadataLabels.from_dict(default_labels), + ), + spec=DriveSpec( + region=default_region, + size=1, + permissions=_make_permissions(permissions), + ), + ) + return await DriveInstance.create(drive) + + +async def _create_sandbox(name: str, labels: dict[str, str]) -> SandboxInstance: + return await SandboxInstance.create( + { + "name": name, + "image": default_image, + "memory": 2048, + "region": default_region, + "labels": {**default_labels, **labels}, + }, + safe=True, + ) + + +async def _exec(sbx: SandboxInstance, command: str) -> tuple[bool, str]: + try: + result = await asyncio.wait_for( + sbx.process.exec({"command": command, "wait_for_completion": True}), + timeout=30, + ) + return (True, result.logs or "") + except Exception as err: + return (False, str(err)) + + +async def _update_drive_permissions(drive_name: str, permissions: list[dict]) -> None: + await settings.authenticate() + url = f"{settings.base_url}/drives/{drive_name}" + perm_dicts = [ + {"labels": p.get("labels", {}), "mode": p.get("mode", "read-write")} for p in permissions + ] + async with httpx.AsyncClient() as http_client: + res = await http_client.put( + url, + headers={"Content-Type": "application/json", **settings.headers}, + json={"metadata": {}, "spec": {"permissions": perm_dicts}}, + ) + assert res.status_code < 400, f"Update permissions failed: {res.status_code} {res.text}" + + +def _is_acl_denial(msg: str) -> bool: + return any( + s in msg for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") + ) + + +class _DriveACLBase: + """Base class with cleanup tracking for drive ACL tests.""" + + created_sandboxes: list[str] = [] + created_drives: list[str] = [] + + @pytest_asyncio.fixture(autouse=True, scope="class", loop_scope="class") + async def cleanup(self, request): + request.cls.created_sandboxes = [] + request.cls.created_drives = [] + yield + await asyncio.gather( + *[self._safe_delete_sandbox(n) for n in request.cls.created_sandboxes], + return_exceptions=True, + ) + await asyncio.gather( + *[self._safe_delete_drive(n) for n in request.cls.created_drives], + return_exceptions=True, + ) + + async def _safe_delete_sandbox(self, name: str) -> None: + try: + await SandboxInstance.delete(name) + await wait_for_sandbox_deletion(name) + except Exception: + pass + + async def _safe_delete_drive(self, name: str) -> None: + try: + await DriveInstance.delete(name) + except Exception: + pass + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLOpenAccess(_DriveACLBase): + """Drive with NO permissions (empty array) -- any sandbox can access.""" + + async def test_open_access_write_and_read(self): + drive_name = unique_name("acl-open") + sbx_name = unique_name("acl-open-sbx") + + await _create_drive_with_permissions(drive_name, []) + self.created_drives.append(drive_name) + + sbx = await _create_sandbox(sbx_name, {"role": "anything"}) + self.created_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/open") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(sbx, "echo 'open-access-ok' > /mnt/open/test.txt") + assert ok, f"write failed: {logs}" + + ok, logs = await _exec(sbx, "cat /mnt/open/test.txt") + assert ok and "open-access-ok" in logs + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLLabelMatch(_DriveACLBase): + """Drive with label-based permission -- matching sandbox gets access.""" + + async def test_label_match_write_and_read(self): + drive_name = unique_name("acl-match") + sbx_name = unique_name("acl-match-sbx") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "backend", "project": "acl-test"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + sbx = await _create_sandbox(sbx_name, {"team": "backend", "project": "acl-test"}) + self.created_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/match") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(sbx, "echo 'label-match-ok' > /mnt/match/test.txt") + assert ok, f"write failed: {logs}" + + ok, logs = await _exec(sbx, "cat /mnt/match/test.txt") + assert ok and "label-match-ok" in logs + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLLabelMismatch(_DriveACLBase): + """Drive with label-based permission -- non-matching sandbox is denied.""" + + async def test_label_mismatch_denied(self): + drive_name = unique_name("acl-mis") + sbx_name = unique_name("acl-mis-sbx") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "secret-team"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + sbx = await _create_sandbox(sbx_name, {"team": "other"}) + self.created_sandboxes.append(sbx_name) + + try: + await sbx.drives.mount(drive_name, "/mnt/mis") + await asyncio.sleep(MOUNT_SETTLE_S) + ok, logs = await _exec(sbx, "echo 'should-fail' > /mnt/mis/test.txt") + assert not ok, f"write should have been denied but succeeded: {logs}" + except Exception as err: + assert _is_acl_denial(str(err)), f"unexpected error (not ACL denial): {err}" + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLReadOnly(_DriveACLBase): + """Read-only mode: matching sandbox can read but NOT write.""" + + async def test_read_only_blocks_writes(self): + drive_name = unique_name("acl-ro") + writer_name = unique_name("acl-ro-writer") + reader_name = unique_name("acl-ro-reader") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"role": "reader"}, "mode": "read"}, + {"labels": {"role": "writer"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + # Writer seeds data + writer = await _create_sandbox(writer_name, {"role": "writer"}) + self.created_sandboxes.append(writer_name) + await writer.drives.mount(drive_name, "/mnt/ro") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(writer, "echo 'read-only-test-data' > /mnt/ro/readonly.txt") + assert ok, f"seed write failed: {logs}" + + # Reader mounts read-only + reader = await _create_sandbox(reader_name, {"role": "reader"}) + self.created_sandboxes.append(reader_name) + await reader.drives.mount(drive_name, "/mnt/ro", read_only=True) + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(reader, "cat /mnt/ro/readonly.txt") + assert ok and "read-only-test-data" in logs, f"read failed: {logs}" + + ok, logs = await _exec(reader, "echo 'should-fail' > /mnt/ro/illegal.txt") + assert not ok or any(s in logs for s in ("denied", "Read-only", "Permission", "error")), ( + f"write should have been denied: {logs}" + ) + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLMultiplePermissionsOR(_DriveACLBase): + """Two permissions with OR logic -- second rule match grants access.""" + + async def test_or_logic_second_rule_matches(self): + drive_name = unique_name("acl-or") + sbx_name = unique_name("acl-or-sbx") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "alpha"}, "mode": "read-write"}, + {"labels": {"team": "beta"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + sbx = await _create_sandbox(sbx_name, {"team": "beta"}) + self.created_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/or") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(sbx, "echo 'or-logic-ok' > /mnt/or/test.txt") + assert ok, f"write failed: {logs}" + + ok, logs = await _exec(sbx, "cat /mnt/or/test.txt") + assert ok and "or-logic-ok" in logs + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLANDLogic(_DriveACLBase): + """AND logic within a single permission -- all labels must match.""" + + async def test_partial_label_match_denied(self): + drive_name = unique_name("acl-and") + sbx_name = unique_name("acl-and-partial") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "core", "tier": "staging"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + # Partial match: has team=core but NOT tier=staging + partial = await _create_sandbox(sbx_name, {"team": "core"}) + self.created_sandboxes.append(sbx_name) + + try: + await partial.drives.mount(drive_name, "/mnt/and") + await asyncio.sleep(MOUNT_SETTLE_S) + ok, logs = await _exec(partial, "echo 'should-fail' > /mnt/and/test.txt") + assert not ok, f"write should have been denied: {logs}" + except Exception as err: + assert _is_acl_denial(str(err)), f"unexpected error: {err}" + + async def test_full_label_match_allowed(self): + drive_name = unique_name("acl-and-f") + sbx_name = unique_name("acl-and-full") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"team": "core", "tier": "staging"}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + full = await _create_sandbox(sbx_name, {"team": "core", "tier": "staging"}) + self.created_sandboxes.append(sbx_name) + + await full.drives.mount(drive_name, "/mnt/and") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(full, "echo 'and-logic-ok' > /mnt/and/test.txt") + assert ok, f"write failed: {logs}" + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLPathScoping(_DriveACLBase): + """Permission restricts access to /data/ subfolder.""" + + async def test_scoped_sandbox_reads_subfolder_only(self): + drive_name = unique_name("acl-path") + admin_name = unique_name("acl-path-admin") + scoped_name = unique_name("acl-path-scoped") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {"role": "admin"}, "mode": "read-write", "path": "/"}, + {"labels": {"role": "scoped"}, "mode": "read-write", "path": "/data"}, + ], + ) + self.created_drives.append(drive_name) + + # Admin seeds data + admin = await _create_sandbox(admin_name, {"role": "admin"}) + self.created_sandboxes.append(admin_name) + await admin.drives.mount(drive_name, "/mnt/path") + await asyncio.sleep(MOUNT_SETTLE_S) + + await _exec(admin, "echo 'root-secret' > /mnt/path/secret.txt") + await _exec(admin, "mkdir -p /mnt/path/data && echo 'data-ok' > /mnt/path/data/file.txt") + + # Scoped sandbox mounts only /data + scoped = await _create_sandbox(scoped_name, {"role": "scoped"}) + self.created_sandboxes.append(scoped_name) + await scoped.drives.mount(drive_name, "/mnt/scoped", drive_path="/data") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(scoped, "cat /mnt/scoped/file.txt") + assert ok and "data-ok" in logs, f"read /data failed: {logs}" + + ok, logs = await _exec(scoped, "cat /mnt/scoped/secret.txt") + assert not ok or "No such file" in logs, f"root file should not be visible: {logs}" + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLUpdatePermissions(_DriveACLBase): + """Update permissions on an existing drive, verify enforcement changes.""" + + async def test_update_permissions_restricts_access(self): + drive_name = unique_name("acl-upd") + sbx_open_name = unique_name("acl-upd-open") + sbx_denied_name = unique_name("acl-upd-denied") + sbx_allowed_name = unique_name("acl-upd-allowed") + + # Step 1: open access + await _create_drive_with_permissions(drive_name, []) + self.created_drives.append(drive_name) + + sbx_open = await _create_sandbox(sbx_open_name, {"role": "tester"}) + self.created_sandboxes.append(sbx_open_name) + await sbx_open.drives.mount(drive_name, "/mnt/upd") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, _ = await _exec(sbx_open, "echo 'before-update' > /mnt/upd/test.txt") + assert ok, "write before restriction failed" + + # Step 2: restrict to team=restricted + await _update_drive_permissions( + drive_name, + [ + {"labels": {"team": "restricted"}, "mode": "read-write"}, + ], + ) + + # Step 3: verify persisted + updated = await DriveInstance.get(drive_name) + perms = updated.spec.permissions if updated.spec else None + assert perms is not None and len(perms) == 1 + assert perms[0].labels is not None + assert perms[0].labels.additional_properties.get("team") == "restricted" + + # Step 4: denied without label + sbx_denied = await _create_sandbox(sbx_denied_name, {"team": "other"}) + self.created_sandboxes.append(sbx_denied_name) + try: + await sbx_denied.drives.mount(drive_name, "/mnt/upd") + await asyncio.sleep(MOUNT_SETTLE_S) + ok, logs = await _exec(sbx_denied, "echo 'should-fail' > /mnt/upd/test.txt") + assert not ok, f"write should have been denied: {logs}" + except Exception as err: + assert _is_acl_denial(str(err)), f"unexpected error: {err}" + + # Step 5: allowed with label + sbx_allowed = await _create_sandbox(sbx_allowed_name, {"team": "restricted"}) + self.created_sandboxes.append(sbx_allowed_name) + await sbx_allowed.drives.mount(drive_name, "/mnt/upd") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(sbx_allowed, "echo 'after-update-ok' > /mnt/upd/test2.txt") + assert ok, f"write with correct labels failed: {logs}" + + ok, logs = await _exec(sbx_allowed, "cat /mnt/upd/test2.txt") + assert ok and "after-update-ok" in logs + + +@pytest.mark.asyncio(loop_scope="class") +class TestDriveACLWildcard(_DriveACLBase): + """Wildcard permission (empty labels = match all workloads).""" + + async def test_wildcard_permission_allows_all(self): + drive_name = unique_name("acl-wild") + sbx_name = unique_name("acl-wild-sbx") + + await _create_drive_with_permissions( + drive_name, + [ + {"labels": {}, "mode": "read-write"}, + ], + ) + self.created_drives.append(drive_name) + + sbx = await _create_sandbox(sbx_name, {"random": "anything"}) + self.created_sandboxes.append(sbx_name) + + await sbx.drives.mount(drive_name, "/mnt/wild") + await asyncio.sleep(MOUNT_SETTLE_S) + + ok, logs = await _exec(sbx, "echo 'wildcard-ok' > /mnt/wild/test.txt") + assert ok, f"write failed: {logs}" + + ok, logs = await _exec(sbx, "cat /mnt/wild/test.txt") + assert ok and "wildcard-ok" in logs diff --git a/tests/manual/__init__.py b/tests/manual/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/manual/drive_acl.py b/tests/manual/drive_acl.py deleted file mode 100644 index 6c1b3af3..00000000 --- a/tests/manual/drive_acl.py +++ /dev/null @@ -1,679 +0,0 @@ -""" -Manual test for per-drive ACL enforcement (ENG-2761). - -Prerequisites (all three PRs must be deployed): - - controlplane#4582 -- DrivePermission model + ACL sync to filer - - seaweedfs#27 -- filer-side ACL enforcement (domain-aware: blaxel.dev / blaxel.ai) - - executionplane#171 -- workload labels in JWT token - -Environment variables: - BL_WORKSPACE -- workspace name - BL_API_KEY -- API key with drive + sandbox permissions - BL_ENV -- "dev" or "prod" (default: "dev") - BL_DRIVE_REGION -- drive region override (default: eu-dub-1 for dev, us-pdx-1 for prod) - -Usage: - uv run python tests/manual/drive_acl.py - uv run python tests/manual/drive_acl.py --scenario open-access - uv run python tests/manual/drive_acl.py --scenario label-match -""" - -from __future__ import annotations - -import asyncio -import base64 -import json -import os -import sys -import uuid -from dataclasses import dataclass -from typing import Any - -import httpx - -from blaxel.core.client.models import Drive, DriveSpec, Metadata, MetadataLabels -from blaxel.core.client.models.drive_permission import DrivePermission -from blaxel.core.client.models.drive_permission_labels import DrivePermissionLabels -from blaxel.core.client.models.drive_permission_mode import DrivePermissionMode -from blaxel.core.client.types import UNSET -from blaxel.core.common.settings import settings -from blaxel.core.drive import DriveInstance -from blaxel.core.sandbox import SandboxInstance - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - -ENV = os.environ.get("BL_ENV", "dev") -REGION = os.environ.get("BL_DRIVE_REGION", "eu-dub-1" if ENV == "dev" else "us-pdx-1") -IMAGE = "blaxel/base-image:latest" -TEST_LABELS = {"env": "manual-test", "created-by": "drive-acl-test"} -EXEC_TIMEOUT_S = 30 -MOUNT_SETTLE_S = 3 - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def uid(prefix: str) -> str: - return f"{prefix}-{uuid.uuid4().hex[:8]}" - - -async def async_sleep(seconds: float) -> None: - await asyncio.sleep(seconds) - - -async def create_drive_with_permissions( - name: str, - permissions: list[dict[str, Any]], -) -> DriveInstance: - perm_objects = [] - for p in permissions: - raw_labels = p.get("labels", {}) - if raw_labels: - labels = DrivePermissionLabels.from_dict(raw_labels) - else: - # Empty dict is falsy, from_dict returns None; construct manually - labels = DrivePermissionLabels() - mode = DrivePermissionMode(p.get("mode", "read-write")) - path = p.get("path", UNSET) - perm_objects.append(DrivePermission(labels=labels, mode=mode, path=path)) - - drive = Drive( - metadata=Metadata( - name=name, - labels=MetadataLabels.from_dict(TEST_LABELS), - ), - spec=DriveSpec( - region=REGION, - size=1, - permissions=perm_objects, - ), - ) - return await DriveInstance.create(drive) - - -async def update_drive_permissions( - drive_name: str, - permissions: list[dict[str, Any]], -) -> None: - await settings.authenticate() - auth_headers = settings.headers - base_url = settings.base_url - url = f"{base_url}/drives/{drive_name}" - - perm_dicts = [] - for p in permissions: - perm_dicts.append( - { - "labels": p.get("labels", {}), - "mode": p.get("mode", "read-write"), - } - ) - - async with httpx.AsyncClient() as http_client: - res = await http_client.put( - url, - headers={"Content-Type": "application/json", **auth_headers}, - json={"metadata": {}, "spec": {"permissions": perm_dicts}}, - ) - if res.status_code >= 400: - raise Exception(f"Failed to update drive permissions: {res.status_code} {res.text}") - - -async def create_sandbox( - name: str, - labels: dict[str, str], -) -> SandboxInstance: - return await SandboxInstance.create( - { - "name": name, - "image": IMAGE, - "memory": 2048, - "region": REGION, - "labels": {**TEST_LABELS, **labels}, - }, - safe=True, - ) - - -async def exec_in_sandbox( - sbx: SandboxInstance, - command: str, -) -> tuple[bool, str]: - try: - result = await asyncio.wait_for( - sbx.process.exec({"command": command, "wait_for_completion": True}), - timeout=EXEC_TIMEOUT_S, - ) - return (True, result.logs or "") - except Exception as err: - return (False, str(err)) - - -# --------------------------------------------------------------------------- -# Cleanup tracker -# --------------------------------------------------------------------------- - -cleanup_sandboxes: list[str] = [] -cleanup_drives: list[str] = [] - - -async def cleanup() -> None: - print("\n--- Cleanup ---") - for name in cleanup_sandboxes: - try: - await SandboxInstance.delete(name) - print(f" deleted sandbox {name}") - except Exception: - pass - await async_sleep(5) - for name in cleanup_drives: - try: - await DriveInstance.delete(name) - print(f" deleted drive {name}") - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Result tracking -# --------------------------------------------------------------------------- - - -@dataclass -class TestResult: - name: str - passed: bool - detail: str - skipped: bool = False - - -results: list[TestResult] = [] - - -def record(name: str, passed: bool, detail: str) -> None: - icon = "PASS" if passed else "FAIL" - print(f" [{icon}] {name}: {detail}") - results.append(TestResult(name=name, passed=passed, detail=detail)) - - -def skip(name: str, reason: str) -> None: - print(f" [SKIP] {name}: {reason}") - results.append(TestResult(name=name, passed=True, detail=reason, skipped=True)) - - -def format_error(err: Exception) -> str: - return str(err) - - -async def debug_jwt(sbx: SandboxInstance) -> dict[str, Any] | None: - domain = "blaxel.dev" if ENV == "dev" else "blaxel.ai" - token_path = f"/var/run/secrets/{domain}/identity/token" - ok, logs = await exec_in_sandbox(sbx, f"cat {token_path}") - if not ok or not logs.strip(): - print(f" [DEBUG] Could not read JWT from {token_path}: {logs}") - return None - try: - parts = logs.strip().split(".") - if len(parts) != 3: - return None - payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload_str = json.dumps(payload, indent=2) - print(f" [DEBUG] JWT claims: {chr(10).join(payload_str.split(chr(10))[:15])}...") - return payload - except Exception: - return None - - -# --------------------------------------------------------------------------- -# Scenarios -# --------------------------------------------------------------------------- - - -async def scenario_open_access() -> None: - """Drive with NO permissions (empty array) -- any sandbox can access.""" - print("\n=== Scenario: open-access (no permissions = allow all) ===") - drive_name = uid("acl-open") - sbx_name = uid("acl-open-sbx") - - await create_drive_with_permissions(drive_name, []) - cleanup_drives.append(drive_name) - - sbx = await create_sandbox(sbx_name, {"role": "anything"}) - cleanup_sandboxes.append(sbx_name) - - await sbx.drives.mount(drive_name, "/mnt/open") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx, "echo 'open-access-ok' > /mnt/open/test.txt") - record("open-access write", ok, "wrote successfully" if ok else logs) - - ok, logs = await exec_in_sandbox(sbx, "cat /mnt/open/test.txt") - record("open-access read", ok and "open-access-ok" in logs, logs.strip()) - - -async def scenario_label_match() -> None: - """Drive with label-based permission -- matching sandbox gets access.""" - print("\n=== Scenario: label-match (sandbox has matching labels) ===") - drive_name = uid("acl-match") - sbx_name = uid("acl-match-sbx") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"team": "backend", "project": "acl-test"}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - sbx = await create_sandbox(sbx_name, {"team": "backend", "project": "acl-test"}) - cleanup_sandboxes.append(sbx_name) - - await debug_jwt(sbx) - - await sbx.drives.mount(drive_name, "/mnt/match") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx, "echo 'label-match-ok' > /mnt/match/test.txt") - record("label-match write", ok, "wrote successfully" if ok else logs) - - ok, logs = await exec_in_sandbox(sbx, "cat /mnt/match/test.txt") - record("label-match read", ok and "label-match-ok" in logs, logs.strip()) - - -async def scenario_label_mismatch() -> None: - """Drive with label-based permission -- non-matching sandbox is denied.""" - print("\n=== Scenario: label-mismatch (sandbox lacks required labels) ===") - drive_name = uid("acl-mis") - sbx_name = uid("acl-mis-sbx") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"team": "secret-team"}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - sbx = await create_sandbox(sbx_name, {"team": "other"}) - cleanup_sandboxes.append(sbx_name) - - try: - await sbx.drives.mount(drive_name, "/mnt/mis") - await async_sleep(MOUNT_SETTLE_S) - ok, logs = await exec_in_sandbox(sbx, "echo 'should-fail' > /mnt/mis/test.txt") - record( - "label-mismatch mount+write denied", - not ok, - f"unexpected success: {logs.strip()}" if ok else "write denied at file level", - ) - except Exception as err: - msg = format_error(err) - is_acl_denial = any( - s in msg - for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") - ) - record( - "label-mismatch mount denied", - is_acl_denial, - "mount correctly denied by ACL" if is_acl_denial else f"unexpected error: {msg}", - ) - - -async def scenario_read_only() -> None: - """Read-only mode: matching sandbox can read but NOT write.""" - print("\n=== Scenario: read-only (mode=read blocks writes) ===") - drive_name = uid("acl-ro") - writer_name = uid("acl-ro-writer") - reader_name = uid("acl-ro-reader") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"role": "reader"}, "mode": "read"}, - {"labels": {"role": "writer"}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - writer = await create_sandbox(writer_name, {"role": "writer"}) - cleanup_sandboxes.append(writer_name) - await writer.drives.mount(drive_name, "/mnt/ro") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(writer, "echo 'read-only-test-data' > /mnt/ro/readonly.txt") - record("read-only seed write", ok, "seeded data" if ok else logs) - - reader = await create_sandbox(reader_name, {"role": "reader"}) - cleanup_sandboxes.append(reader_name) - try: - await reader.drives.mount(drive_name, "/mnt/ro", read_only=True) - except Exception as err: - msg = format_error(err) - record("read-only mount", False, f"mount failed: {msg}") - return - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(reader, "cat /mnt/ro/readonly.txt") - record("read-only read succeeds", ok and "read-only-test-data" in logs, logs.strip()) - - ok, logs = await exec_in_sandbox(reader, "echo 'should-fail' > /mnt/ro/illegal.txt") - record( - "read-only write denied", - not ok or any(s in logs for s in ("denied", "Read-only", "Permission", "error")), - f"unexpected success: {logs.strip()}" if ok else "write denied as expected", - ) - - -async def scenario_multiple_permissions_or() -> None: - """Two permissions with OR logic -- second rule match grants access.""" - print("\n=== Scenario: multiple-permissions-or (first match wins) ===") - drive_name = uid("acl-or") - sbx_name = uid("acl-or-sbx") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"team": "alpha"}, "mode": "read-write"}, - {"labels": {"team": "beta"}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - sbx = await create_sandbox(sbx_name, {"team": "beta"}) - cleanup_sandboxes.append(sbx_name) - - await sbx.drives.mount(drive_name, "/mnt/or") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx, "echo 'or-logic-ok' > /mnt/or/test.txt") - record("or-logic write", ok, "wrote successfully" if ok else logs) - - ok, logs = await exec_in_sandbox(sbx, "cat /mnt/or/test.txt") - record("or-logic read", ok and "or-logic-ok" in logs, logs.strip()) - - -async def scenario_and_logic() -> None: - """AND logic within a single permission -- all labels must match.""" - print("\n=== Scenario: and-logic (all labels must match within a permission) ===") - drive_name = uid("acl-and") - sbx_partial_name = uid("acl-and-partial") - sbx_full_name = uid("acl-and-full") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"team": "core", "tier": "staging"}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - # Partial match: has team=core but NOT tier=staging -- should be denied - partial = await create_sandbox(sbx_partial_name, {"team": "core"}) - cleanup_sandboxes.append(sbx_partial_name) - try: - await partial.drives.mount(drive_name, "/mnt/and") - await async_sleep(MOUNT_SETTLE_S) - ok, logs = await exec_in_sandbox(partial, "echo 'should-fail' > /mnt/and/test.txt") - record( - "and-logic partial denied", - not ok, - f"unexpected success: {logs.strip()}" if ok else "denied at file level", - ) - except Exception as err: - msg = format_error(err) - is_acl_denial = any( - s in msg - for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") - ) - record( - "and-logic partial denied", - is_acl_denial, - "mount correctly denied (partial label match)" - if is_acl_denial - else f"unexpected error: {msg}", - ) - - # Full match: has both team=core AND tier=staging -- should succeed - full = await create_sandbox(sbx_full_name, {"team": "core", "tier": "staging"}) - cleanup_sandboxes.append(sbx_full_name) - await full.drives.mount(drive_name, "/mnt/and") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(full, "echo 'and-logic-ok' > /mnt/and/test.txt") - record("and-logic full-match write", ok, "wrote successfully" if ok else logs) - - -async def scenario_path_scoping() -> None: - """Permission restricts access to /data/ subfolder.""" - print("\n=== Scenario: path-scoping (permission restricts to subfolder) ===") - drive_name = uid("acl-path") - writer_name = uid("acl-path-writer") - scoped_name = uid("acl-path-scoped") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {"role": "admin"}, "mode": "read-write", "path": "/"}, - {"labels": {"role": "scoped"}, "mode": "read-write", "path": "/data"}, - ], - ) - cleanup_drives.append(drive_name) - - # Admin sandbox: seed data in both root and /data/ - admin = await create_sandbox(writer_name, {"role": "admin"}) - cleanup_sandboxes.append(writer_name) - await admin.drives.mount(drive_name, "/mnt/path") - await async_sleep(MOUNT_SETTLE_S) - - await exec_in_sandbox(admin, "echo 'root-secret' > /mnt/path/secret.txt") - await exec_in_sandbox( - admin, "mkdir -p /mnt/path/data && echo 'data-ok' > /mnt/path/data/file.txt" - ) - - # Scoped sandbox: mount with drive_path="/data" (only has access to /data) - scoped = await create_sandbox(scoped_name, {"role": "scoped"}) - cleanup_sandboxes.append(scoped_name) - await scoped.drives.mount(drive_name, "/mnt/scoped", drive_path="/data") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(scoped, "cat /mnt/scoped/file.txt") - record("path-scoping /data read", ok and "data-ok" in logs, logs.strip()) - - ok, logs = await exec_in_sandbox(scoped, "cat /mnt/scoped/secret.txt") - record( - "path-scoping root not visible", - not ok or "No such file" in logs, - f"unexpected: {logs.strip()}" if ok else "root files not visible as expected", - ) - - -async def scenario_update_permissions() -> None: - """Update permissions on an existing drive, verify enforcement changes.""" - print("\n=== Scenario: update-permissions (edit permissions on existing drive) ===") - drive_name = uid("acl-upd") - sbx_open_name = uid("acl-upd-open") - sbx_denied_name = uid("acl-upd-denied") - sbx_allowed_name = uid("acl-upd-allowed") - - # Step 1: Create drive with NO permissions (open access) - await create_drive_with_permissions(drive_name, []) - cleanup_drives.append(drive_name) - - sbx_open = await create_sandbox(sbx_open_name, {"role": "tester"}) - cleanup_sandboxes.append(sbx_open_name) - await sbx_open.drives.mount(drive_name, "/mnt/upd") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx_open, "echo 'before-update' > /mnt/upd/test.txt") - record("update-permissions open write", ok, "wrote before restriction" if ok else logs) - - # Step 2: Update drive to restrict permissions - await update_drive_permissions( - drive_name, - [ - {"labels": {"team": "restricted"}, "mode": "read-write"}, - ], - ) - record("update-permissions API call", True, "permissions updated successfully") - - # Step 3: Verify the update persisted - updated = await DriveInstance.get(drive_name) - perms = updated.spec.permissions if updated.spec else None - has_perms = ( - perms is not None - and len(perms) == 1 - and hasattr(perms[0], "labels") - and perms[0].labels is not None - and perms[0].labels.additional_properties.get("team") == "restricted" - ) - record( - "update-permissions persisted", - has_perms, - "permissions correctly saved" if has_perms else f"got: {perms}", - ) - - # Step 4: New sandbox WITHOUT the label should be denied - sbx_denied = await create_sandbox(sbx_denied_name, {"team": "other"}) - cleanup_sandboxes.append(sbx_denied_name) - try: - await sbx_denied.drives.mount(drive_name, "/mnt/upd") - await async_sleep(MOUNT_SETTLE_S) - ok, logs = await exec_in_sandbox(sbx_denied, "echo 'should-fail' > /mnt/upd/test.txt") - record( - "update-permissions denied after update", - not ok, - f"unexpected success: {logs.strip()}" if ok else "write denied at file level", - ) - except Exception as err: - msg = format_error(err) - is_acl_denial = any( - s in msg - for s in ("timeout", "denied", "Permission", "exited unexpectedly: exit status 2") - ) - record( - "update-permissions denied after update", - is_acl_denial, - "mount correctly denied after permission update" - if is_acl_denial - else f"unexpected error: {msg}", - ) - - # Step 5: New sandbox WITH the label should succeed - sbx_allowed = await create_sandbox(sbx_allowed_name, {"team": "restricted"}) - cleanup_sandboxes.append(sbx_allowed_name) - await sbx_allowed.drives.mount(drive_name, "/mnt/upd") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx_allowed, "echo 'after-update-ok' > /mnt/upd/test2.txt") - record( - "update-permissions allowed after update", ok, "wrote with correct labels" if ok else logs - ) - - ok, logs = await exec_in_sandbox(sbx_allowed, "cat /mnt/upd/test2.txt") - record("update-permissions allowed read", ok and "after-update-ok" in logs, logs.strip()) - - -async def scenario_wildcard_permission() -> None: - """Wildcard permission (empty labels = match all workloads).""" - print("\n=== Scenario: wildcard-permission (empty labels match all) ===") - drive_name = uid("acl-wild") - sbx_name = uid("acl-wild-sbx") - - await create_drive_with_permissions( - drive_name, - [ - {"labels": {}, "mode": "read-write"}, - ], - ) - cleanup_drives.append(drive_name) - - sbx = await create_sandbox(sbx_name, {"random": "anything"}) - cleanup_sandboxes.append(sbx_name) - - await sbx.drives.mount(drive_name, "/mnt/wild") - await async_sleep(MOUNT_SETTLE_S) - - ok, logs = await exec_in_sandbox(sbx, "echo 'wildcard-ok' > /mnt/wild/test.txt") - record("wildcard-permission write", ok, "wrote successfully" if ok else logs) - - ok, logs = await exec_in_sandbox(sbx, "cat /mnt/wild/test.txt") - record("wildcard-permission read", ok and "wildcard-ok" in logs, logs.strip()) - - -# --------------------------------------------------------------------------- -# Scenario registry -# --------------------------------------------------------------------------- - -SCENARIOS: dict[str, Any] = { - "open-access": scenario_open_access, - "label-match": scenario_label_match, - "label-mismatch": scenario_label_mismatch, - "read-only": scenario_read_only, - "multiple-permissions-or": scenario_multiple_permissions_or, - "and-logic": scenario_and_logic, - "path-scoping": scenario_path_scoping, - "update-permissions": scenario_update_permissions, - "wildcard-permission": scenario_wildcard_permission, -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -async def main() -> None: - args = sys.argv[1:] - selected_scenario: str | None = None - if "--scenario" in args: - idx = args.index("--scenario") - if idx + 1 < len(args): - selected_scenario = args[idx + 1] - - print("Drive ACL Manual Test (ENG-2761)") - print(f" env={ENV} region={REGION}") - print(f" scenarios={selected_scenario or 'all'}") - - if selected_scenario and selected_scenario not in SCENARIOS: - print(f"Unknown scenario: {selected_scenario}") - print(f"Available: {', '.join(SCENARIOS.keys())}") - sys.exit(1) - - to_run = {selected_scenario: SCENARIOS[selected_scenario]} if selected_scenario else SCENARIOS - - try: - for name, fn in to_run.items(): - try: - await fn() - except Exception as err: - record(f"{name} (scenario error)", False, format_error(err)) - finally: - await cleanup() - - # Summary - print("\n" + "=" * 60) - print("SUMMARY") - print("=" * 60) - - passed = [r for r in results if r.passed and not r.skipped] - skipped = [r for r in results if r.skipped] - failed = [r for r in results if not r.passed] - - print(f" Total: {len(results)}") - print(f" Passed: {len(passed)}") - print(f" Skipped: {len(skipped)}") - print(f" Failed: {len(failed)}") - - if failed: - print("\nFailed checks:") - for f in failed: - print(f" - {f.name}: {f.detail}") - - print() - sys.exit(1 if failed else 0) - - -if __name__ == "__main__": - asyncio.run(main()) From bdde339e7f1169442eb6f7ed4e9705bfffcac41e Mon Sep 17 00:00:00 2001 From: cdrappier Date: Wed, 24 Jun 2026 20:52:06 +0000 Subject: [PATCH 5/5] fix: use generated update_drive API instead of raw httpx in ACL test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../core/sandbox/test_drive_acl.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/integration/core/sandbox/test_drive_acl.py b/tests/integration/core/sandbox/test_drive_acl.py index fbeb2623..288ba5b1 100644 --- a/tests/integration/core/sandbox/test_drive_acl.py +++ b/tests/integration/core/sandbox/test_drive_acl.py @@ -17,16 +17,16 @@ import asyncio import os -import httpx import pytest import pytest_asyncio +from blaxel.core.client.api.drives.update_drive import asyncio as update_drive_api +from blaxel.core.client.client import client from blaxel.core.client.models import Drive, DriveSpec, Metadata, MetadataLabels from blaxel.core.client.models.drive_permission import DrivePermission from blaxel.core.client.models.drive_permission_labels import DrivePermissionLabels from blaxel.core.client.models.drive_permission_mode import DrivePermissionMode from blaxel.core.client.types import UNSET -from blaxel.core.common.settings import settings from blaxel.core.drive import DriveInstance from blaxel.core.sandbox import SandboxInstance from tests.helpers import ( @@ -95,18 +95,17 @@ async def _exec(sbx: SandboxInstance, command: str) -> tuple[bool, str]: async def _update_drive_permissions(drive_name: str, permissions: list[dict]) -> None: - await settings.authenticate() - url = f"{settings.base_url}/drives/{drive_name}" - perm_dicts = [ - {"labels": p.get("labels", {}), "mode": p.get("mode", "read-write")} for p in permissions - ] - async with httpx.AsyncClient() as http_client: - res = await http_client.put( - url, - headers={"Content-Type": "application/json", **settings.headers}, - json={"metadata": {}, "spec": {"permissions": perm_dicts}}, - ) - assert res.status_code < 400, f"Update permissions failed: {res.status_code} {res.text}" + current = await DriveInstance.get(drive_name) + body = Drive( + metadata=current.drive.metadata, + spec=DriveSpec( + region=current.drive.spec.region if current.drive.spec else UNSET, + size=current.drive.spec.size if current.drive.spec else UNSET, + permissions=_make_permissions(permissions), + ), + ) + response = await update_drive_api(drive_name=drive_name, client=client, body=body) + assert response is not None, f"Update permissions failed for {drive_name}" def _is_acl_denial(msg: str) -> bool: