diff --git a/sdks/sandbox/python/src/opensandbox/__init__.py b/sdks/sandbox/python/src/opensandbox/__init__.py index 7e786bea..66ffb650 100644 --- a/sdks/sandbox/python/src/opensandbox/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/__init__.py @@ -100,8 +100,9 @@ async def main(): from importlib.metadata import version as _pkg_version from opensandbox.manager import SandboxManager +from opensandbox.pool_manager import PoolManager from opensandbox.sandbox import Sandbox -from opensandbox.sync import SandboxManagerSync, SandboxSync +from opensandbox.sync import SandboxManagerSync, SandboxSync, PoolManagerSync try: __version__ = _pkg_version("opensandbox") @@ -114,4 +115,6 @@ async def main(): "SandboxManager", "SandboxSync", "SandboxManagerSync", + "PoolManager", + "PoolManagerSync", ] diff --git a/sdks/sandbox/python/src/opensandbox/adapters/factory.py b/sdks/sandbox/python/src/opensandbox/adapters/factory.py index f9f68c47..a8897f64 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/factory.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/factory.py @@ -28,6 +28,7 @@ from opensandbox.adapters.filesystem_adapter import FilesystemAdapter from opensandbox.adapters.health_adapter import HealthAdapter from opensandbox.adapters.metrics_adapter import MetricsAdapter +from opensandbox.adapters.pools_adapter import PoolsAdapter from opensandbox.adapters.sandboxes_adapter import SandboxesAdapter from opensandbox.config import ConnectionConfig from opensandbox.models.sandboxes import SandboxEndpoint @@ -35,6 +36,7 @@ from opensandbox.services.filesystem import Filesystem from opensandbox.services.health import Health from opensandbox.services.metrics import Metrics +from opensandbox.services.pool import Pools from opensandbox.services.sandbox import Sandboxes @@ -111,3 +113,11 @@ def create_metrics_service(self, endpoint: SandboxEndpoint) -> Metrics: Service for collecting sandbox resource usage metrics """ return MetricsAdapter(self.connection_config, endpoint) + + def create_pool_service(self) -> Pools: + """Create a pool management service for CRUD operations on resource pools. + + Returns: + Service for creating, listing, updating, and deleting pre-warmed pools + """ + return PoolsAdapter(self.connection_config) diff --git a/sdks/sandbox/python/src/opensandbox/adapters/pools_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/pools_adapter.py new file mode 100644 index 00000000..6ae25921 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/adapters/pools_adapter.py @@ -0,0 +1,214 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Pool service adapter implementation. + +Implements the Pools Protocol by calling the lifecycle API over HTTP, +following the same patterns as SandboxesAdapter. +""" + +import logging + +import httpx + +from opensandbox.adapters.converter.exception_converter import ExceptionConverter +from opensandbox.adapters.converter.response_handler import handle_api_error, require_parsed +from opensandbox.api.lifecycle import AuthenticatedClient +from opensandbox.api.lifecycle.models.pool_capacity_spec import ApiPoolCapacitySpec +from opensandbox.api.lifecycle.models.create_pool_request import ApiCreatePoolRequest +from opensandbox.api.lifecycle.models.update_pool_request import ApiUpdatePoolRequest +from opensandbox.api.lifecycle.models.pool_response import ApiPoolResponse +from opensandbox.api.lifecycle.models.list_pools_response import ApiListPoolsResponse +from opensandbox.api.lifecycle.types import Unset +from opensandbox.config import ConnectionConfig +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolParams, +) +from opensandbox.services.pool import Pools + +logger = logging.getLogger(__name__) + + +class PoolsAdapter(Pools): + """ + HTTP adapter that implements the Pools protocol. + + Calls the lifecycle API's /pools endpoints and converts between + API (attrs) models and domain (Pydantic) models. + """ + + def __init__(self, connection_config: ConnectionConfig) -> None: + self._connection_config = connection_config + api_key = connection_config.get_api_key() + timeout_seconds = connection_config.request_timeout.total_seconds() + timeout = httpx.Timeout(timeout_seconds) + headers = { + "User-Agent": connection_config.user_agent, + **connection_config.headers, + } + if api_key: + headers["OPEN-SANDBOX-API-KEY"] = api_key + + self._client = AuthenticatedClient( + base_url=connection_config.get_base_url(), + token=api_key or "", + prefix="", + auth_header_name="OPEN-SANDBOX-API-KEY", + timeout=timeout, + ) + self._httpx_client = httpx.AsyncClient( + base_url=connection_config.get_base_url(), + headers=headers, + timeout=timeout, + transport=connection_config.transport, + ) + self._client.set_async_httpx_client(self._httpx_client) + + # ------------------------------------------------------------------ + # Conversion helpers + # ------------------------------------------------------------------ + + @staticmethod + def _to_api_capacity(spec: PoolCapacitySpec) -> ApiPoolCapacitySpec: + return ApiPoolCapacitySpec( + buffer_max=spec.buffer_max, + buffer_min=spec.buffer_min, + pool_max=spec.pool_max, + pool_min=spec.pool_min, + ) + + @staticmethod + def _from_api_pool(raw: ApiPoolResponse) -> PoolInfo: + cap = raw.capacity_spec + capacity_spec = PoolCapacitySpec( + bufferMax=cap.buffer_max, + bufferMin=cap.buffer_min, + poolMax=cap.pool_max, + poolMin=cap.pool_min, + ) + status = None + if not isinstance(raw.status, Unset) and raw.status is not None: + s = raw.status + status = PoolStatus( + total=s.total, + allocated=s.allocated, + available=s.available, + revision=s.revision, + ) + created_at = None + if not isinstance(raw.created_at, Unset): + created_at = raw.created_at + + return PoolInfo( + name=raw.name, + capacitySpec=capacity_spec, + status=status, + createdAt=created_at, + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def create_pool(self, params: CreatePoolParams) -> PoolInfo: + logger.info("Creating pool: name=%s", params.name) + try: + from opensandbox.api.lifecycle.api.pools import post_pools + + body = ApiCreatePoolRequest( + name=params.name, + template=params.template, + capacity_spec=self._to_api_capacity(params.capacity_spec), + ) + response_obj = await post_pools.asyncio_detailed( + client=self._client, body=body + ) + handle_api_error(response_obj, f"Create pool '{params.name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Create pool '{params.name}'") + result = self._from_api_pool(parsed) + logger.info("Successfully created pool: %s", result.name) + return result + except Exception as e: + logger.error("Failed to create pool '%s'", params.name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + async def get_pool(self, pool_name: str) -> PoolInfo: + logger.debug("Getting pool: %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import get_pools_pool_name + + response_obj = await get_pools_pool_name.asyncio_detailed( + pool_name, client=self._client + ) + handle_api_error(response_obj, f"Get pool '{pool_name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Get pool '{pool_name}'") + return self._from_api_pool(parsed) + except Exception as e: + logger.error("Failed to get pool '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + async def list_pools(self) -> PoolListResponse: + logger.debug("Listing pools") + try: + from opensandbox.api.lifecycle.api.pools import get_pools + + response_obj = await get_pools.asyncio_detailed(client=self._client) + handle_api_error(response_obj, "List pools") + parsed = require_parsed(response_obj, ApiListPoolsResponse, "List pools") + items = [self._from_api_pool(item) for item in parsed.items] + return PoolListResponse(items=items) + except Exception as e: + logger.error("Failed to list pools", exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + async def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + logger.info("Updating pool capacity: %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import put_pools_pool_name + + body = ApiUpdatePoolRequest( + capacity_spec=self._to_api_capacity(params.capacity_spec) + ) + response_obj = await put_pools_pool_name.asyncio_detailed( + pool_name, client=self._client, body=body + ) + handle_api_error(response_obj, f"Update pool '{pool_name}'") + parsed = require_parsed(response_obj, ApiPoolResponse, f"Update pool '{pool_name}'") + result = self._from_api_pool(parsed) + logger.info("Successfully updated pool: %s", pool_name) + return result + except Exception as e: + logger.error("Failed to update pool '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e + + async def delete_pool(self, pool_name: str) -> None: + logger.info("Deleting pool: %s", pool_name) + try: + from opensandbox.api.lifecycle.api.pools import delete_pools_pool_name + + response_obj = await delete_pools_pool_name.asyncio_detailed( + pool_name, client=self._client + ) + handle_api_error(response_obj, f"Delete pool '{pool_name}'") + logger.info("Successfully deleted pool: %s", pool_name) + except Exception as e: + logger.error("Failed to delete pool '%s'", pool_name, exc_info=e) + raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/__init__.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/delete_pools_pool_name.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/delete_pools_pool_name.py new file mode 100644 index 00000000..d9d38ca6 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/delete_pools_pool_name.py @@ -0,0 +1,68 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...types import Response + + +def _get_kwargs(pool_name: str) -> dict[str, Any]: + return {"method": "delete", "url": f"/pools/{pool_name}"} + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | None: + if response.status_code == 204: + return None + if response.status_code in (401, 404, 500, 501): + return ErrorResponse.from_dict(response.json()) + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | None]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + pool_name: str, *, client: AuthenticatedClient | Client +) -> Response[ErrorResponse | None]: + """Delete a pool.""" + response = client.get_httpx_client().request(**_get_kwargs(pool_name)) + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + pool_name: str, *, client: AuthenticatedClient | Client +) -> Response[ErrorResponse | None]: + """Delete a pool.""" + response = await client.get_async_httpx_client().request(**_get_kwargs(pool_name)) + return _build_response(client=client, response=response) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools.py new file mode 100644 index 00000000..78213499 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools.py @@ -0,0 +1,69 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.list_pools_response import ApiListPoolsResponse +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + return {"method": "get", "url": "/pools"} + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiListPoolsResponse | ErrorResponse | None: + if response.status_code == 200: + return ApiListPoolsResponse.from_dict(response.json()) + if response.status_code in (401, 500, 501): + return ErrorResponse.from_dict(response.json()) + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiListPoolsResponse | ErrorResponse]: + 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: AuthenticatedClient | Client +) -> Response[ApiListPoolsResponse | ErrorResponse]: + """List all pre-warmed resource pools.""" + response = client.get_httpx_client().request(**_get_kwargs()) + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, client: AuthenticatedClient | Client +) -> Response[ApiListPoolsResponse | ErrorResponse]: + """List all pre-warmed resource pools.""" + response = await client.get_async_httpx_client().request(**_get_kwargs()) + return _build_response(client=client, response=response) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools_pool_name.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools_pool_name.py new file mode 100644 index 00000000..c0d96796 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/get_pools_pool_name.py @@ -0,0 +1,69 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.pool_response import ApiPoolResponse +from ...types import Response + + +def _get_kwargs(pool_name: str) -> dict[str, Any]: + return {"method": "get", "url": f"/pools/{pool_name}"} + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiPoolResponse | ErrorResponse | None: + if response.status_code == 200: + return ApiPoolResponse.from_dict(response.json()) + if response.status_code in (401, 404, 500, 501): + return ErrorResponse.from_dict(response.json()) + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiPoolResponse | ErrorResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + pool_name: str, *, client: AuthenticatedClient | Client +) -> Response[ApiPoolResponse | ErrorResponse]: + """Retrieve a pool by name.""" + response = client.get_httpx_client().request(**_get_kwargs(pool_name)) + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + pool_name: str, *, client: AuthenticatedClient | Client +) -> Response[ApiPoolResponse | ErrorResponse]: + """Retrieve a pool by name.""" + response = await client.get_async_httpx_client().request(**_get_kwargs(pool_name)) + return _build_response(client=client, response=response) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/post_pools.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/post_pools.py new file mode 100644 index 00000000..b6498b32 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/post_pools.py @@ -0,0 +1,80 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_pool_request import ApiCreatePoolRequest +from ...models.error_response import ErrorResponse +from ...models.pool_response import ApiPoolResponse +from ...types import Response + + +def _get_kwargs(*, body: ApiCreatePoolRequest) -> dict[str, Any]: + headers: dict[str, Any] = {} + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/pools", + } + _kwargs["json"] = body.to_dict() + headers["Content-Type"] = "application/json" + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiPoolResponse | ErrorResponse | None: + if response.status_code == 201: + return ApiPoolResponse.from_dict(response.json()) + if response.status_code in (400, 401, 409, 500, 501): + return ErrorResponse.from_dict(response.json()) + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiPoolResponse | ErrorResponse]: + 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: AuthenticatedClient | Client, body: ApiCreatePoolRequest +) -> Response[ApiPoolResponse | ErrorResponse]: + """Create a pre-warmed resource pool.""" + kwargs = _get_kwargs(body=body) + response = client.get_httpx_client().request(**kwargs) + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, client: AuthenticatedClient | Client, body: ApiCreatePoolRequest +) -> Response[ApiPoolResponse | ErrorResponse]: + """Create a pre-warmed resource pool.""" + kwargs = _get_kwargs(body=body) + response = await client.get_async_httpx_client().request(**kwargs) + return _build_response(client=client, response=response) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/put_pools_pool_name.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/put_pools_pool_name.py new file mode 100644 index 00000000..25d8828c --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/api/pools/put_pools_pool_name.py @@ -0,0 +1,80 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.pool_response import ApiPoolResponse +from ...models.update_pool_request import ApiUpdatePoolRequest +from ...types import Response + + +def _get_kwargs(pool_name: str, *, body: ApiUpdatePoolRequest) -> dict[str, Any]: + headers: dict[str, Any] = {} + _kwargs: dict[str, Any] = { + "method": "put", + "url": f"/pools/{pool_name}", + } + _kwargs["json"] = body.to_dict() + headers["Content-Type"] = "application/json" + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiPoolResponse | ErrorResponse | None: + if response.status_code == 200: + return ApiPoolResponse.from_dict(response.json()) + if response.status_code in (400, 401, 404, 500, 501): + return ErrorResponse.from_dict(response.json()) + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiPoolResponse | ErrorResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + pool_name: str, *, client: AuthenticatedClient | Client, body: ApiUpdatePoolRequest +) -> Response[ApiPoolResponse | ErrorResponse]: + """Update pool capacity configuration.""" + response = client.get_httpx_client().request(**_get_kwargs(pool_name, body=body)) + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + pool_name: str, *, client: AuthenticatedClient | Client, body: ApiUpdatePoolRequest +) -> Response[ApiPoolResponse | ErrorResponse]: + """Update pool capacity configuration.""" + response = await client.get_async_httpx_client().request( + **_get_kwargs(pool_name, body=body) + ) + return _build_response(client=client, response=response) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py index 945ac192..4fe7bf68 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py @@ -42,6 +42,12 @@ from .sandbox_metadata import SandboxMetadata from .sandbox_status import SandboxStatus from .volume import Volume +from .create_pool_request import ApiCreatePoolRequest +from .update_pool_request import ApiUpdatePoolRequest +from .pool_capacity_spec import ApiPoolCapacitySpec +from .pool_status import ApiPoolStatus +from .pool_response import ApiPoolResponse +from .list_pools_response import ApiListPoolsResponse __all__ = ( "CreateSandboxRequest", @@ -70,4 +76,10 @@ "SandboxMetadata", "SandboxStatus", "Volume", + "ApiCreatePoolRequest", + "ApiUpdatePoolRequest", + "ApiPoolCapacitySpec", + "ApiPoolStatus", + "ApiPoolResponse", + "ApiListPoolsResponse", ) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_pool_request.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_pool_request.py new file mode 100644 index 00000000..a8841d95 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_pool_request.py @@ -0,0 +1,84 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + +T = TypeVar("T", bound="ApiCreatePoolRequest") + + +@_attrs_define +class ApiCreatePoolRequest: + """Request body for creating a new pool. + + Attributes: + name: Pool name (Kubernetes resource name). + template: PodTemplateSpec dict. + capacity_spec: Capacity configuration. + """ + + name: str + template: dict[str, Any] + capacity_spec: ApiPoolCapacitySpec + 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) + field_dict.update( + { + "name": self.name, + "template": self.template, + "capacitySpec": self.capacity_spec.to_dict(), + } + ) + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + + d = dict(src_dict) + name = d.pop("name") + template = d.pop("template") + capacity_spec = ApiPoolCapacitySpec.from_dict(d.pop("capacitySpec")) + obj = cls(name=name, template=template, capacity_spec=capacity_spec) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/list_pools_response.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/list_pools_response.py new file mode 100644 index 00000000..858fd059 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/list_pools_response.py @@ -0,0 +1,75 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.pool_response import ApiPoolResponse + +T = TypeVar("T", bound="ApiListPoolsResponse") + + +@_attrs_define +class ApiListPoolsResponse: + """Response from listing pools. + + Attributes: + items: List of pool resources. + """ + + items: list[ApiPoolResponse] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + items = [item.to_dict() for item in self.items] + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict["items"] = items + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.pool_response import ApiPoolResponse + + d = dict(src_dict) + items = [ApiPoolResponse.from_dict(item) for item in d.pop("items", [])] + obj = cls(items=items) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_capacity_spec.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_capacity_spec.py new file mode 100644 index 00000000..c5bd1401 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_capacity_spec.py @@ -0,0 +1,90 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ApiPoolCapacitySpec") + + +@_attrs_define +class ApiPoolCapacitySpec: + """API model for pool capacity configuration. + + Attributes: + buffer_max: Maximum warm-buffer size. + buffer_min: Minimum warm-buffer size. + pool_max: Maximum total pool size. + pool_min: Minimum total pool size. + """ + + buffer_max: int + buffer_min: int + pool_max: int + pool_min: int + 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) + field_dict.update( + { + "bufferMax": self.buffer_max, + "bufferMin": self.buffer_min, + "poolMax": self.pool_max, + "poolMin": self.pool_min, + } + ) + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + buffer_max = d.pop("bufferMax") + buffer_min = d.pop("bufferMin") + pool_max = d.pop("poolMax") + pool_min = d.pop("poolMin") + obj = cls( + buffer_max=buffer_max, + buffer_min=buffer_min, + pool_max=pool_max, + pool_min=pool_min, + ) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_response.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_response.py new file mode 100644 index 00000000..93b205cb --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_response.py @@ -0,0 +1,126 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + from ..models.pool_status import ApiPoolStatus + +T = TypeVar("T", bound="ApiPoolResponse") + + +@_attrs_define +class ApiPoolResponse: + """Full representation of a Pool resource. + + Attributes: + name: Unique pool name. + capacity_spec: Capacity configuration. + status: Observed runtime state (may be absent before first reconcile). + created_at: Pool creation timestamp. + """ + + name: str + capacity_spec: ApiPoolCapacitySpec + status: ApiPoolStatus | Unset = UNSET + created_at: datetime.datetime | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + + capacity_spec = self.capacity_spec.to_dict() + + status: dict[str, Any] | Unset = UNSET + if not isinstance(self.status, Unset): + status = self.status.to_dict() + + created_at: str | Unset = UNSET + if not isinstance(self.created_at, Unset): + created_at = self.created_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": self.name, + "capacitySpec": capacity_spec, + } + ) + if status is not UNSET: + field_dict["status"] = status + if created_at is not UNSET: + field_dict["createdAt"] = created_at + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + from ..models.pool_status import ApiPoolStatus + + d = dict(src_dict) + name = d.pop("name") + capacity_spec = ApiPoolCapacitySpec.from_dict(d.pop("capacitySpec")) + + _status = d.pop("status", UNSET) + status: ApiPoolStatus | Unset + if isinstance(_status, Unset): + status = UNSET + else: + status = ApiPoolStatus.from_dict(_status) + + _created_at = d.pop("createdAt", UNSET) + created_at: datetime.datetime | Unset + if isinstance(_created_at, Unset): + created_at = UNSET + else: + created_at = isoparse(_created_at) + + obj = cls( + name=name, + capacity_spec=capacity_spec, + status=status, + created_at=created_at, + ) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_status.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_status.py new file mode 100644 index 00000000..1b2432bf --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pool_status.py @@ -0,0 +1,86 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ApiPoolStatus") + + +@_attrs_define +class ApiPoolStatus: + """Observed runtime state of a pool. + + Attributes: + total: Total number of pods in the pool. + allocated: Number of pods currently allocated to sandboxes. + available: Number of pods available in the warm buffer. + revision: Latest revision identifier. + """ + + total: int + allocated: int + available: int + revision: str + 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) + field_dict.update( + { + "total": self.total, + "allocated": self.allocated, + "available": self.available, + "revision": self.revision, + } + ) + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + obj = cls( + total=d.pop("total"), + allocated=d.pop("allocated"), + available=d.pop("available"), + revision=d.pop("revision"), + ) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/update_pool_request.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/update_pool_request.py new file mode 100644 index 00000000..24c327c8 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/update_pool_request.py @@ -0,0 +1,72 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + +T = TypeVar("T", bound="ApiUpdatePoolRequest") + + +@_attrs_define +class ApiUpdatePoolRequest: + """Request body for updating a pool's capacity. + + Attributes: + capacity_spec: New capacity configuration. + """ + + capacity_spec: ApiPoolCapacitySpec + 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) + field_dict["capacitySpec"] = self.capacity_spec.to_dict() + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.pool_capacity_spec import ApiPoolCapacitySpec + + d = dict(src_dict) + capacity_spec = ApiPoolCapacitySpec.from_dict(d.pop("capacitySpec")) + obj = cls(capacity_spec=capacity_spec) + obj.additional_properties = d + return obj + + @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/sdks/sandbox/python/src/opensandbox/models/pools.py b/sdks/sandbox/python/src/opensandbox/models/pools.py new file mode 100644 index 00000000..f24c68b5 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/models/pools.py @@ -0,0 +1,164 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Pool-related domain models. + +Models for pool creation, configuration, and status management. +Pools represent pre-warmed sets of sandbox pods that reduce cold-start latency. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class PoolCapacitySpec(BaseModel): + """ + Capacity configuration for a pre-warmed pool. + + Controls how many pods are kept warm and the overall pool size limits. + """ + + model_config = ConfigDict(populate_by_name=True) + + buffer_max: int = Field( + ..., + alias="bufferMax", + ge=0, + description="Maximum number of pods kept in the warm buffer.", + ) + buffer_min: int = Field( + ..., + alias="bufferMin", + ge=0, + description="Minimum number of pods that must remain in the buffer.", + ) + pool_max: int = Field( + ..., + alias="poolMax", + ge=0, + description="Maximum total number of pods allowed in the pool.", + ) + pool_min: int = Field( + ..., + alias="poolMin", + ge=0, + description="Minimum total size of the pool.", + ) + + +class PoolStatus(BaseModel): + """Observed runtime state of a pool reported by the controller.""" + + total: int = Field(..., description="Total number of pods in the pool.") + allocated: int = Field(..., description="Number of pods currently allocated to sandboxes.") + available: int = Field(..., description="Number of pods currently available in the warm buffer.") + revision: str = Field(..., description="Latest revision identifier of the pool spec.") + + +class PoolInfo(BaseModel): + """ + Complete representation of a Pool resource. + + Returned by create/get/update operations and as items in list responses. + """ + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., description="Unique pool name (Kubernetes resource name).") + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="Capacity configuration of the pool.", + ) + status: PoolStatus | None = Field( + None, + description="Observed runtime state. May be None if not yet reconciled by the controller.", + ) + created_at: datetime | None = Field( + None, + alias="createdAt", + description="Pool creation timestamp.", + ) + + +class PoolListResponse(BaseModel): + """Response from listing pools.""" + + items: list[PoolInfo] = Field(..., description="List of pools.") + + +class CreatePoolParams(BaseModel): + """ + Parameters for creating a new Pool. + + Usage:: + + params = CreatePoolParams( + name="my-pool", + template={"spec": {"containers": [{"name": "sandbox", "image": "python:3.11"}]}}, + capacity_spec=PoolCapacitySpec(bufferMax=3, bufferMin=1, poolMax=10, poolMin=0), + ) + pool = await manager.create_pool(params) + """ + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field( + ..., + description="Unique name for the pool (must be a valid Kubernetes resource name).", + pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + max_length=253, + ) + template: dict[str, Any] = Field( + ..., + description=( + "Kubernetes PodTemplateSpec defining the pod configuration for pre-warmed pods. " + "Follows the same schema as spec.template in a Kubernetes Deployment." + ), + ) + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="Initial capacity configuration controlling pool size and buffer behavior.", + ) + + +class UpdatePoolParams(BaseModel): + """ + Parameters for updating an existing Pool's capacity. + + Only ``capacity_spec`` can be updated after pool creation. + To change the pod template, delete and recreate the pool. + + Usage:: + + params = UpdatePoolParams( + capacity_spec=PoolCapacitySpec(bufferMax=5, bufferMin=2, poolMax=20, poolMin=0) + ) + updated = await manager.update_pool("my-pool", params) + """ + + model_config = ConfigDict(populate_by_name=True) + + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="New capacity configuration for the pool.", + ) diff --git a/sdks/sandbox/python/src/opensandbox/pool_manager.py b/sdks/sandbox/python/src/opensandbox/pool_manager.py new file mode 100644 index 00000000..8d1eebe0 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/pool_manager.py @@ -0,0 +1,259 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Pool management interface for administrative pool operations. + +Provides a high-level async interface for creating and managing pre-warmed +sandbox resource pools. Use PoolManager when you want to manage the pool +infrastructure independently of individual sandbox instances. + +Usage:: + + async with await PoolManager.create() as manager: + pool = await manager.create_pool( + name="my-pool", + template={"spec": {"containers": [{"name": "sbx", "image": "python:3.11"}]}}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + print(pool.name, pool.status) +""" + +from __future__ import annotations + +import logging +from typing import Any + +from opensandbox.adapters.factory import AdapterFactory +from opensandbox.config import ConnectionConfig +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + UpdatePoolParams, +) +from opensandbox.services.pool import Pools + +logger = logging.getLogger(__name__) + + +class PoolManager: + """ + High-level async interface for managing pre-warmed sandbox resource pools. + + Pools are Kubernetes CRD resources that keep a set of pods pre-warmed, + reducing sandbox cold-start latency. This manager exposes simple CRUD + methods that map to the server's ``/pools`` API. + + **Creating a manager**:: + + manager = await PoolManager.create(connection_config=config) + + **Using as an async context manager** (recommended):: + + async with await PoolManager.create() as manager: + pool = await manager.create_pool( + name="my-pool", + template={...}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + + **Cleanup**: Call ``await manager.close()`` (or use the async context manager) + to release HTTP client resources. + + **Note**: Pool management requires the server to be running with + ``runtime.type = 'kubernetes'``. Non-Kubernetes deployments return a + ``SandboxApiException`` with status 501. + """ + + def __init__( + self, + pool_service: Pools, + connection_config: ConnectionConfig, + ) -> None: + self._pool_service = pool_service + self._connection_config = connection_config + + @property + def connection_config(self) -> ConnectionConfig: + """Connection configuration used by this manager.""" + return self._connection_config + + @classmethod + async def create( + cls, connection_config: ConnectionConfig | None = None + ) -> "PoolManager": + """ + Create a PoolManager with the provided (or default) connection config. + + Args: + connection_config: Connection configuration. If ``None``, the default + configuration (env vars / defaults) is used. + + Returns: + Configured PoolManager instance. + """ + config = (connection_config or ConnectionConfig()).with_transport_if_missing() + factory = AdapterFactory(config) + pool_service = factory.create_pool_service() + return cls(pool_service, config) + + # ------------------------------------------------------------------ + # Pool CRUD + # ------------------------------------------------------------------ + + async def create_pool( + self, + name: str, + template: dict[str, Any], + *, + buffer_max: int, + buffer_min: int, + pool_max: int, + pool_min: int, + ) -> PoolInfo: + """ + Create a new pre-warmed resource pool. + + Args: + name: Unique pool name (must be a valid Kubernetes resource name). + template: Kubernetes PodTemplateSpec dict for the pre-warmed pods. + buffer_max: Maximum number of pods in the warm buffer. + buffer_min: Minimum number of pods in the warm buffer. + pool_max: Maximum total pool size. + pool_min: Minimum total pool size. + + Returns: + PoolInfo representing the newly created pool. + + Raises: + SandboxException: if the operation fails. + """ + params = CreatePoolParams( + name=name, + template=template, + capacitySpec=PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ), + ) + logger.info("Creating pool: %s", name) + return await self._pool_service.create_pool(params) + + async def get_pool(self, pool_name: str) -> PoolInfo: + """ + Retrieve a pool by name. + + Args: + pool_name: Name of the pool to retrieve. + + Returns: + Current PoolInfo including observed runtime status. + + Raises: + SandboxException: if the operation fails. + """ + logger.debug("Getting pool: %s", pool_name) + return await self._pool_service.get_pool(pool_name) + + async def list_pools(self) -> PoolListResponse: + """ + List all pools. + + Returns: + PoolListResponse containing all pools in the namespace. + + Raises: + SandboxException: if the operation fails. + """ + logger.debug("Listing pools") + return await self._pool_service.list_pools() + + async def update_pool( + self, + pool_name: str, + *, + buffer_max: int, + buffer_min: int, + pool_max: int, + pool_min: int, + ) -> PoolInfo: + """ + Update the capacity configuration of an existing pool. + + Only capacity values can be changed after creation. To change the + pod template, delete and recreate the pool. + + Args: + pool_name: Name of the pool to update. + buffer_max: New maximum warm-buffer size. + buffer_min: New minimum warm-buffer size. + pool_max: New maximum total pool size. + pool_min: New minimum total pool size. + + Returns: + Updated PoolInfo. + + Raises: + SandboxException: if the operation fails. + """ + params = UpdatePoolParams( + capacitySpec=PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ) + ) + logger.info("Updating pool capacity: %s", pool_name) + return await self._pool_service.update_pool(pool_name, params) + + async def delete_pool(self, pool_name: str) -> None: + """ + Delete a pool. + + Args: + pool_name: Name of the pool to delete. + + Raises: + SandboxException: if the operation fails. + """ + logger.info("Deleting pool: %s", pool_name) + await self._pool_service.delete_pool(pool_name) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Release HTTP client resources owned by this manager.""" + try: + await self._connection_config.close_transport_if_owned() + except Exception as e: + logger.warning("Error closing PoolManager resources: %s", e, exc_info=True) + + async def __aenter__(self) -> "PoolManager": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + await self.close() diff --git a/sdks/sandbox/python/src/opensandbox/services/pool.py b/sdks/sandbox/python/src/opensandbox/services/pool.py new file mode 100644 index 00000000..43e094e2 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/services/pool.py @@ -0,0 +1,110 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Pool service interface. + +Protocol for pool lifecycle management operations. +""" + +from typing import Protocol + +from opensandbox.models.pools import ( + CreatePoolParams, + PoolInfo, + PoolListResponse, + UpdatePoolParams, +) + + +class Pools(Protocol): + """ + Pool management service protocol. + + Abstracts CRUD operations for Pool resources, completely isolating + business logic from HTTP/API implementation details. + """ + + async def create_pool(self, params: CreatePoolParams) -> PoolInfo: + """ + Create a new pre-warmed resource pool. + + Args: + params: Pool creation parameters including name, template, and capacity. + + Returns: + PoolInfo for the newly created pool. + + Raises: + SandboxException: if the operation fails. + """ + ... + + async def get_pool(self, pool_name: str) -> PoolInfo: + """ + Retrieve a pool by name. + + Args: + pool_name: Name of the pool to retrieve. + + Returns: + Current PoolInfo including observed runtime status. + + Raises: + SandboxException: if the operation fails. + """ + ... + + async def list_pools(self) -> PoolListResponse: + """ + List all pools. + + Returns: + PoolListResponse containing all pools. + + Raises: + SandboxException: if the operation fails. + """ + ... + + async def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + """ + Update the capacity configuration of an existing pool. + + Only ``capacity_spec`` can be changed after creation. + + Args: + pool_name: Name of the pool to update. + params: Update parameters with the new capacity spec. + + Returns: + Updated PoolInfo. + + Raises: + SandboxException: if the operation fails. + """ + ... + + async def delete_pool(self, pool_name: str) -> None: + """ + Delete a pool. + + Args: + pool_name: Name of the pool to delete. + + Raises: + SandboxException: if the operation fails. + """ + ... diff --git a/sdks/sandbox/python/tests/test_pool_manager.py b/sdks/sandbox/python/tests/test_pool_manager.py new file mode 100644 index 00000000..bb8fefae --- /dev/null +++ b/sdks/sandbox/python/tests/test_pool_manager.py @@ -0,0 +1,644 @@ +# +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Tests for the Pool SDK layer: + - Domain models (models/pools.py) + - API lifecycle models (api/lifecycle/models/pool_*.py) + - PoolsAdapter (adapters/pools_adapter.py) + - PoolManager (pool_manager.py) +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from opensandbox.api.lifecycle.models.list_pools_response import ApiListPoolsResponse +from opensandbox.api.lifecycle.models.pool_capacity_spec import ApiPoolCapacitySpec +from opensandbox.api.lifecycle.models.pool_response import ApiPoolResponse +from opensandbox.api.lifecycle.models.pool_status import ApiPoolStatus +from opensandbox.api.lifecycle.types import UNSET +from opensandbox.config import ConnectionConfig +from opensandbox.exceptions import SandboxApiException +from opensandbox.models.pools import ( + CreatePoolParams, + PoolCapacitySpec, + PoolInfo, + PoolListResponse, + PoolStatus, + UpdatePoolParams, +) +from opensandbox.pool_manager import PoolManager + + +# --------------------------------------------------------------------------- +# Domain model tests +# --------------------------------------------------------------------------- + +class TestPoolCapacitySpecModel: + def test_accepts_alias_names(self): + spec = PoolCapacitySpec(bufferMax=3, bufferMin=1, poolMax=10, poolMin=0) + assert spec.buffer_max == 3 + assert spec.pool_max == 10 + + def test_accepts_snake_names(self): + spec = PoolCapacitySpec(buffer_max=3, buffer_min=1, pool_max=10, pool_min=0) + assert spec.buffer_max == 3 + + def test_rejects_negative_values(self): + from pydantic import ValidationError + with pytest.raises(ValidationError): + PoolCapacitySpec(bufferMax=-1, bufferMin=0, poolMax=5, poolMin=0) + + def test_zero_values_are_valid(self): + spec = PoolCapacitySpec(bufferMax=0, bufferMin=0, poolMax=0, poolMin=0) + assert spec.buffer_max == 0 + + +class TestCreatePoolParamsModel: + def test_valid_name_accepted(self): + p = CreatePoolParams( + name="my-pool", + template={}, + capacitySpec=PoolCapacitySpec(bufferMax=1, bufferMin=0, poolMax=5, poolMin=0), + ) + assert p.name == "my-pool" + + def test_invalid_name_rejected(self): + from pydantic import ValidationError + with pytest.raises(ValidationError): + CreatePoolParams( + name="Invalid_Name", + template={}, + capacitySpec=PoolCapacitySpec(bufferMax=1, bufferMin=0, poolMax=5, poolMin=0), + ) + + def test_uppercase_name_rejected(self): + from pydantic import ValidationError + with pytest.raises(ValidationError): + CreatePoolParams( + name="MyPool", + template={}, + capacitySpec=PoolCapacitySpec(bufferMax=1, bufferMin=0, poolMax=5, poolMin=0), + ) + + +# --------------------------------------------------------------------------- +# API lifecycle model tests +# --------------------------------------------------------------------------- + +class TestApiPoolCapacitySpec: + def test_to_dict_uses_camel_case_keys(self): + spec = ApiPoolCapacitySpec(buffer_max=3, buffer_min=1, pool_max=10, pool_min=0) + d = spec.to_dict() + assert d["bufferMax"] == 3 + assert d["bufferMin"] == 1 + assert d["poolMax"] == 10 + assert d["poolMin"] == 0 + + def test_round_trip(self): + original = ApiPoolCapacitySpec(buffer_max=5, buffer_min=2, pool_max=20, pool_min=1) + restored = ApiPoolCapacitySpec.from_dict(original.to_dict()) + assert restored.buffer_max == 5 + assert restored.pool_min == 1 + + +class TestApiPoolResponse: + def _make_raw(self, name="pool-a", with_status=True, with_created=True) -> ApiPoolResponse: + cap = ApiPoolCapacitySpec(buffer_max=3, buffer_min=1, pool_max=10, pool_min=0) + status = ApiPoolStatus(total=2, allocated=1, available=1, revision="r1") if with_status else UNSET + created_at = datetime(2025, 1, 1, tzinfo=timezone.utc) if with_created else UNSET + return ApiPoolResponse(name=name, capacity_spec=cap, status=status, created_at=created_at) + + def test_to_dict_includes_all_fields(self): + obj = self._make_raw() + d = obj.to_dict() + assert d["name"] == "pool-a" + assert "capacitySpec" in d + assert "status" in d + assert "createdAt" in d + + def test_to_dict_omits_unset_fields(self): + obj = self._make_raw(with_status=False, with_created=False) + d = obj.to_dict() + assert "status" not in d + assert "createdAt" not in d + + def test_from_dict_with_full_payload(self): + payload = { + "name": "pool-b", + "capacitySpec": {"bufferMax": 2, "bufferMin": 0, "poolMax": 8, "poolMin": 0}, + "status": {"total": 3, "allocated": 2, "available": 1, "revision": "abc"}, + "createdAt": "2025-06-01T00:00:00+00:00", + } + obj = ApiPoolResponse.from_dict(payload) + assert obj.name == "pool-b" + assert obj.capacity_spec.pool_max == 8 + assert not isinstance(obj.status, type(UNSET)) + assert obj.status.total == 3 + + def test_from_dict_without_status(self): + payload = { + "name": "pool-c", + "capacitySpec": {"bufferMax": 1, "bufferMin": 0, "poolMax": 5, "poolMin": 0}, + } + obj = ApiPoolResponse.from_dict(payload) + assert isinstance(obj.status, type(UNSET)) + + +class TestApiListPoolsResponse: + def test_from_dict_empty_items(self): + obj = ApiListPoolsResponse.from_dict({"items": []}) + assert obj.items == [] + + def test_from_dict_multiple_items(self): + payload = { + "items": [ + { + "name": "p1", + "capacitySpec": {"bufferMax": 1, "bufferMin": 0, "poolMax": 5, "poolMin": 0}, + }, + { + "name": "p2", + "capacitySpec": {"bufferMax": 2, "bufferMin": 1, "poolMax": 10, "poolMin": 0}, + }, + ] + } + obj = ApiListPoolsResponse.from_dict(payload) + assert len(obj.items) == 2 + assert {p.name for p in obj.items} == {"p1", "p2"} + + +# --------------------------------------------------------------------------- +# Stub pool service for PoolManager tests +# --------------------------------------------------------------------------- + +class _PoolServiceStub: + """In-memory Pools stub – no HTTP calls.""" + + def __init__(self) -> None: + self._pools: dict[str, PoolInfo] = {} + self.create_calls: list[CreatePoolParams] = [] + self.update_calls: list[tuple[str, UpdatePoolParams]] = [] + self.delete_calls: list[str] = [] + + def _make_pool(self, name: str, cap: PoolCapacitySpec) -> PoolInfo: + return PoolInfo( + name=name, + capacitySpec=cap, + status=PoolStatus(total=0, allocated=0, available=0, revision="init"), + ) + + async def create_pool(self, params: CreatePoolParams) -> PoolInfo: + self.create_calls.append(params) + pool = self._make_pool(params.name, params.capacity_spec) + self._pools[params.name] = pool + return pool + + async def get_pool(self, pool_name: str) -> PoolInfo: + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + return self._pools[pool_name] + + async def list_pools(self) -> PoolListResponse: + return PoolListResponse(items=list(self._pools.values())) + + async def update_pool(self, pool_name: str, params: UpdatePoolParams) -> PoolInfo: + self.update_calls.append((pool_name, params)) + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + updated = self._make_pool(pool_name, params.capacity_spec) + self._pools[pool_name] = updated + return updated + + async def delete_pool(self, pool_name: str) -> None: + self.delete_calls.append(pool_name) + if pool_name not in self._pools: + raise SandboxApiException(message=f"Pool '{pool_name}' not found.", status_code=404) + del self._pools[pool_name] + + +# --------------------------------------------------------------------------- +# PoolManager tests (using stub service) +# --------------------------------------------------------------------------- + +class TestPoolManagerCreate: + @pytest.mark.asyncio + async def test_create_pool_stores_and_returns_pool(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + pool = await mgr.create_pool( + name="ci-pool", + template={"spec": {}}, + buffer_max=3, buffer_min=1, pool_max=10, pool_min=0, + ) + + assert pool.name == "ci-pool" + assert pool.capacity_spec.buffer_max == 3 + assert pool.capacity_spec.pool_max == 10 + + @pytest.mark.asyncio + async def test_create_pool_passes_correct_params_to_service(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + await mgr.create_pool( + name="my-pool", + template={"spec": {}}, + buffer_max=5, buffer_min=2, pool_max=20, pool_min=1, + ) + + assert len(stub.create_calls) == 1 + params = stub.create_calls[0] + assert params.name == "my-pool" + assert params.capacity_spec.buffer_max == 5 + assert params.capacity_spec.pool_min == 1 + + @pytest.mark.asyncio + async def test_create_pool_propagates_service_exception(self): + stub = _PoolServiceStub() + stub.create_pool = AsyncMock( + side_effect=SandboxApiException("already exists", status_code=409) + ) + mgr = PoolManager(stub, ConnectionConfig()) + + with pytest.raises(SandboxApiException) as exc_info: + await mgr.create_pool("dup", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + assert exc_info.value.status_code == 409 + + +class TestPoolManagerGet: + @pytest.mark.asyncio + async def test_get_existing_pool(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("p1", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + pool = await mgr.get_pool("p1") + assert pool.name == "p1" + + @pytest.mark.asyncio + async def test_get_missing_pool_raises_exception(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + with pytest.raises(SandboxApiException) as exc_info: + await mgr.get_pool("ghost") + + assert exc_info.value.status_code == 404 + + +class TestPoolManagerList: + @pytest.mark.asyncio + async def test_list_empty(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + result = await mgr.list_pools() + assert result.items == [] + + @pytest.mark.asyncio + async def test_list_returns_all_pools(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("a", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + await mgr.create_pool("b", {}, buffer_max=2, buffer_min=1, pool_max=8, pool_min=0) + + result = await mgr.list_pools() + assert len(result.items) == 2 + assert {p.name for p in result.items} == {"a", "b"} + + +class TestPoolManagerUpdate: + @pytest.mark.asyncio + async def test_update_pool_capacity(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("p", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + updated = await mgr.update_pool( + "p", buffer_max=9, buffer_min=3, pool_max=50, pool_min=0 + ) + assert updated.capacity_spec.buffer_max == 9 + assert updated.capacity_spec.pool_max == 50 + + @pytest.mark.asyncio + async def test_update_passes_correct_params_to_service(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("p", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + await mgr.update_pool("p", buffer_max=7, buffer_min=2, pool_max=30, pool_min=0) + + assert len(stub.update_calls) == 1 + name, params = stub.update_calls[0] + assert name == "p" + assert params.capacity_spec.buffer_max == 7 + + @pytest.mark.asyncio + async def test_update_missing_pool_raises_exception(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + with pytest.raises(SandboxApiException) as exc_info: + await mgr.update_pool("ghost", buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + assert exc_info.value.status_code == 404 + + +class TestPoolManagerDelete: + @pytest.mark.asyncio + async def test_delete_existing_pool(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("bye", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + await mgr.delete_pool("bye") + assert "bye" not in stub._pools + + @pytest.mark.asyncio + async def test_delete_calls_service_with_correct_name(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + await mgr.create_pool("to-delete", {}, buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + + await mgr.delete_pool("to-delete") + assert stub.delete_calls == ["to-delete"] + + @pytest.mark.asyncio + async def test_delete_missing_pool_raises_exception(self): + stub = _PoolServiceStub() + mgr = PoolManager(stub, ConnectionConfig()) + + with pytest.raises(SandboxApiException): + await mgr.delete_pool("ghost") + + +# --------------------------------------------------------------------------- +# PoolManager lifecycle tests +# --------------------------------------------------------------------------- + +class TestPoolManagerLifecycle: + @pytest.mark.asyncio + async def test_async_context_manager_calls_close(self): + stub = _PoolServiceStub() + config = ConnectionConfig() + mgr = PoolManager(stub, config) + close_called = [] + + async def fake_close(): + close_called.append(True) + + mgr.close = fake_close + async with mgr: + pass + + assert close_called == [True] + + @pytest.mark.asyncio + async def test_create_factory_method_returns_manager(self): + with patch("opensandbox.pool_manager.AdapterFactory") as MockFactory: + mock_pool_svc = MagicMock() + MockFactory.return_value.create_pool_service.return_value = mock_pool_svc + + mgr = await PoolManager.create() + + assert isinstance(mgr, PoolManager) + assert mgr._pool_service is mock_pool_svc + + +# --------------------------------------------------------------------------- +# PoolsAdapter conversion tests +# --------------------------------------------------------------------------- + +class TestPoolsAdapterConversion: + """Test the static helpers in PoolsAdapter without HTTP.""" + + def test_to_api_capacity_maps_fields(self): + from opensandbox.adapters.pools_adapter import PoolsAdapter + + spec = PoolCapacitySpec(bufferMax=3, bufferMin=1, poolMax=10, poolMin=0) + api_spec = PoolsAdapter._to_api_capacity(spec) + assert api_spec.buffer_max == 3 + assert api_spec.pool_max == 10 + + def test_from_api_pool_with_status(self): + from opensandbox.adapters.pools_adapter import PoolsAdapter + + cap = ApiPoolCapacitySpec(buffer_max=2, buffer_min=0, pool_max=8, pool_min=0) + status = ApiPoolStatus(total=3, allocated=1, available=2, revision="rev-x") + raw = ApiPoolResponse( + name="test-pool", + capacity_spec=cap, + status=status, + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + info = PoolsAdapter._from_api_pool(raw) + + assert info.name == "test-pool" + assert info.capacity_spec.buffer_max == 2 + assert info.capacity_spec.pool_max == 8 + assert info.status is not None + assert info.status.total == 3 + assert info.status.revision == "rev-x" + assert info.created_at is not None + + def test_from_api_pool_without_status(self): + from opensandbox.adapters.pools_adapter import PoolsAdapter + + cap = ApiPoolCapacitySpec(buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + raw = ApiPoolResponse(name="no-status", capacity_spec=cap) + info = PoolsAdapter._from_api_pool(raw) + + assert info.status is None + assert info.created_at is None + + def test_from_api_pool_with_unset_status(self): + from opensandbox.adapters.pools_adapter import PoolsAdapter + + cap = ApiPoolCapacitySpec(buffer_max=1, buffer_min=0, pool_max=5, pool_min=0) + raw = ApiPoolResponse(name="unset-status", capacity_spec=cap, status=UNSET) + info = PoolsAdapter._from_api_pool(raw) + + assert info.status is None + + +# --------------------------------------------------------------------------- +# PoolsAdapter HTTP call tests (mocked httpx) +# --------------------------------------------------------------------------- + +def _make_mock_response(status_code: int, json_body: dict) -> MagicMock: + """Create a fake httpx.Response-like object.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_body + resp.content = b"" + resp.headers = {} + return resp + + +def _make_adapter() -> tuple: + """Return (adapter, mock_async_client).""" + from opensandbox.adapters.pools_adapter import PoolsAdapter + + config = ConnectionConfig(api_key="test-key") + with patch("opensandbox.adapters.pools_adapter.httpx.AsyncClient") as MockClient: + mock_async_http = AsyncMock() + MockClient.return_value = mock_async_http + + with patch("opensandbox.adapters.pools_adapter.AuthenticatedClient"): + adapter = PoolsAdapter(config) + adapter._client = MagicMock() + adapter._client.raise_on_unexpected_status = False + adapter._client.get_async_httpx_client.return_value = mock_async_http + + return adapter, mock_async_http + + +class TestPoolsAdapterHTTP: + @pytest.mark.asyncio + async def test_create_pool_calls_post_endpoint(self): + adapter, mock_http = _make_adapter() + resp_json = { + "name": "new-pool", + "capacitySpec": {"bufferMax": 3, "bufferMin": 1, "poolMax": 10, "poolMin": 0}, + "status": {"total": 0, "allocated": 0, "available": 0, "revision": "init"}, + } + mock_http.request = AsyncMock( + return_value=_make_mock_response(201, resp_json) + ) + + params = CreatePoolParams( + name="new-pool", + template={}, + capacitySpec=PoolCapacitySpec(bufferMax=3, bufferMin=1, poolMax=10, poolMin=0), + ) + result = await adapter.create_pool(params) + + assert result.name == "new-pool" + assert mock_http.request.called + call_kwargs = mock_http.request.call_args.kwargs + assert call_kwargs["method"] == "post" + assert call_kwargs["url"] == "/pools" + + @pytest.mark.asyncio + async def test_get_pool_calls_correct_url(self): + adapter, mock_http = _make_adapter() + resp_json = { + "name": "my-pool", + "capacitySpec": {"bufferMax": 2, "bufferMin": 0, "poolMax": 8, "poolMin": 0}, + } + mock_http.request = AsyncMock( + return_value=_make_mock_response(200, resp_json) + ) + + result = await adapter.get_pool("my-pool") + + assert result.name == "my-pool" + call_kwargs = mock_http.request.call_args.kwargs + assert "/pools/my-pool" in call_kwargs["url"] + + @pytest.mark.asyncio + async def test_list_pools_returns_all_items(self): + adapter, mock_http = _make_adapter() + resp_json = { + "items": [ + { + "name": "p1", + "capacitySpec": {"bufferMax": 1, "bufferMin": 0, "poolMax": 5, "poolMin": 0}, + }, + { + "name": "p2", + "capacitySpec": {"bufferMax": 2, "bufferMin": 1, "poolMax": 10, "poolMin": 0}, + }, + ] + } + mock_http.request = AsyncMock( + return_value=_make_mock_response(200, resp_json) + ) + + result = await adapter.list_pools() + + assert len(result.items) == 2 + assert {p.name for p in result.items} == {"p1", "p2"} + call_kwargs = mock_http.request.call_args.kwargs + assert call_kwargs["url"] == "/pools" + assert call_kwargs["method"] == "get" + + @pytest.mark.asyncio + async def test_update_pool_calls_put_endpoint(self): + adapter, mock_http = _make_adapter() + resp_json = { + "name": "target", + "capacitySpec": {"bufferMax": 9, "bufferMin": 3, "poolMax": 50, "poolMin": 0}, + } + mock_http.request = AsyncMock( + return_value=_make_mock_response(200, resp_json) + ) + + params = UpdatePoolParams( + capacitySpec=PoolCapacitySpec(bufferMax=9, bufferMin=3, poolMax=50, poolMin=0) + ) + result = await adapter.update_pool("target", params) + + assert result.capacity_spec.buffer_max == 9 + call_kwargs = mock_http.request.call_args.kwargs + assert call_kwargs["method"] == "put" + assert "/pools/target" in call_kwargs["url"] + + @pytest.mark.asyncio + async def test_delete_pool_calls_delete_endpoint(self): + adapter, mock_http = _make_adapter() + mock_http.request = AsyncMock( + return_value=_make_mock_response(204, {}) + ) + + await adapter.delete_pool("bye-pool") + + call_kwargs = mock_http.request.call_args.kwargs + assert call_kwargs["method"] == "delete" + assert "/pools/bye-pool" in call_kwargs["url"] + + @pytest.mark.asyncio + async def test_create_pool_raises_on_4xx_error(self): + adapter, mock_http = _make_adapter() + mock_http.request = AsyncMock( + return_value=_make_mock_response(409, {"code": "CONFLICT", "message": "exists"}) + ) + + params = CreatePoolParams( + name="dup", + template={}, + capacitySpec=PoolCapacitySpec(bufferMax=1, bufferMin=0, poolMax=5, poolMin=0), + ) + with pytest.raises(SandboxApiException): + await adapter.create_pool(params) + + @pytest.mark.asyncio + async def test_get_pool_raises_on_404(self): + adapter, mock_http = _make_adapter() + mock_http.request = AsyncMock( + return_value=_make_mock_response(404, {"code": "NOT_FOUND", "message": "not found"}) + ) + + with pytest.raises(SandboxApiException): + await adapter.get_pool("ghost")