From a7570bf50cb4dd6b55d8c6ab314687c7b8beb3fd Mon Sep 17 00:00:00 2001 From: papilonwang Date: Thu, 5 Mar 2026 15:00:39 +0800 Subject: [PATCH] feat(server): add Pool CRUD API and Kubernetes CRD service - Add Pool Pydantic schemas: PoolCapacitySpec, PoolStatus, CreatePoolRequest, UpdatePoolRequest, PoolResponse, ListPoolsResponse - Add PoolService backed by Kubernetes CustomObjectsApi (CRUD on sandbox.opensandbox.io/v1alpha1 SandboxPool resources) - Add FastAPI router: POST/GET /pools, GET/PUT/DELETE /pools/{pool_name} - Return HTTP 501 for non-Kubernetes runtimes - Add error code constants: K8S_POOL_NOT_FOUND, K8S_POOL_ALREADY_EXISTS, K8S_POOL_API_ERROR, K8S_POOL_NOT_SUPPORTED - Add 63 tests (29 unit + 34 integration) --- server/src/api/pool.py | 239 ++++++++++++ server/src/api/schema.py | 125 ++++++ server/src/main.py | 3 + server/src/services/constants.py | 6 + server/src/services/k8s/pool_service.py | 406 +++++++++++++++++++ server/tests/k8s/test_pool_service.py | 491 +++++++++++++++++++++++ server/tests/test_pool_api.py | 496 ++++++++++++++++++++++++ 7 files changed, 1766 insertions(+) create mode 100644 server/src/api/pool.py create mode 100644 server/src/services/k8s/pool_service.py create mode 100644 server/tests/k8s/test_pool_service.py create mode 100644 server/tests/test_pool_api.py diff --git a/server/src/api/pool.py b/server/src/api/pool.py new file mode 100644 index 00000000..f2764bc7 --- /dev/null +++ b/server/src/api/pool.py @@ -0,0 +1,239 @@ +# 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. + +""" +API routes for Pool resource management. + +Pools are pre-warmed sets of sandbox pods that reduce cold-start latency. +These endpoints are only available when the runtime is configured as 'kubernetes'. +""" + +from typing import Optional + +from fastapi import APIRouter, Header, status +from fastapi.exceptions import HTTPException +from fastapi.responses import Response + +from src.api.schema import ( + CreatePoolRequest, + ErrorResponse, + ListPoolsResponse, + PoolResponse, + UpdatePoolRequest, +) +from src.config import get_config +from src.services.constants import SandboxErrorCodes + +router = APIRouter(tags=["Pools"]) + +_POOL_NOT_K8S_DETAIL = { + "code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, + "message": "Pool management is only available when runtime.type is 'kubernetes'.", +} + + +def _get_pool_service(): + """ + Lazily create the PoolService, raising 501 if the runtime is not Kubernetes. + + This deferred approach means the pool router can be registered unconditionally + in main.py; non-k8s deployments simply receive a clear 501 on every call. + """ + from src.services.k8s.client import K8sClient + from src.services.k8s.pool_service import PoolService + + config = get_config() + if config.runtime.type != "kubernetes": + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail=_POOL_NOT_K8S_DETAIL, + ) + + if not config.kubernetes: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail=_POOL_NOT_K8S_DETAIL, + ) + + k8s_client = K8sClient(config.kubernetes) + return PoolService(k8s_client, namespace=config.kubernetes.namespace) + + +# ============================================================================ +# Pool CRUD Endpoints +# ============================================================================ + +@router.post( + "/pools", + response_model=PoolResponse, + response_model_exclude_none=True, + status_code=status.HTTP_201_CREATED, + responses={ + 201: {"description": "Pool created successfully"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 409: {"model": ErrorResponse, "description": "A pool with the same name already exists"}, + 501: {"model": ErrorResponse, "description": "Pool management is not supported in this runtime"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def create_pool( + request: CreatePoolRequest, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID"), +) -> PoolResponse: + """ + Create a pre-warmed resource pool. + + Creates a Pool CRD resource that manages a set of pre-warmed pods. + Once created, sandboxes can reference the pool via ``extensions.poolRef`` + during sandbox creation to benefit from reduced cold-start latency. + + Args: + request: Pool creation request including name, pod template, and capacity spec. + x_request_id: Optional request tracing identifier. + + Returns: + PoolResponse: The newly created pool. + """ + pool_service = _get_pool_service() + return pool_service.create_pool(request) + + +@router.get( + "/pools", + response_model=ListPoolsResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "List of pools"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 501: {"model": ErrorResponse, "description": "Pool management is not supported in this runtime"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def list_pools( + x_request_id: Optional[str] = Header(None, alias="X-Request-ID"), +) -> ListPoolsResponse: + """ + List all pre-warmed resource pools. + + Returns all Pool resources in the configured namespace. + + Args: + x_request_id: Optional request tracing identifier. + + Returns: + ListPoolsResponse: Collection of all pools. + """ + pool_service = _get_pool_service() + return pool_service.list_pools() + + +@router.get( + "/pools/{pool_name}", + response_model=PoolResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Pool retrieved successfully"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 404: {"model": ErrorResponse, "description": "The requested pool does not exist"}, + 501: {"model": ErrorResponse, "description": "Pool management is not supported in this runtime"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def get_pool( + pool_name: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID"), +) -> PoolResponse: + """ + Retrieve a pool by name. + + Args: + pool_name: Name of the pool to retrieve. + x_request_id: Optional request tracing identifier. + + Returns: + PoolResponse: Current state of the pool including runtime status. + """ + pool_service = _get_pool_service() + return pool_service.get_pool(pool_name) + + +@router.put( + "/pools/{pool_name}", + response_model=PoolResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Pool capacity updated successfully"}, + 400: {"model": ErrorResponse, "description": "The request was invalid or malformed"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 404: {"model": ErrorResponse, "description": "The requested pool does not exist"}, + 501: {"model": ErrorResponse, "description": "Pool management is not supported in this runtime"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def update_pool( + pool_name: str, + request: UpdatePoolRequest, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID"), +) -> PoolResponse: + """ + Update pool capacity configuration. + + Only ``capacitySpec`` (bufferMax, bufferMin, poolMax, poolMin) can be + modified after creation. To change the pod template, delete and recreate + the pool. + + Args: + pool_name: Name of the pool to update. + request: Update request with the new capacity spec. + x_request_id: Optional request tracing identifier. + + Returns: + PoolResponse: Updated pool state. + """ + pool_service = _get_pool_service() + return pool_service.update_pool(pool_name, request) + + +@router.delete( + "/pools/{pool_name}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Pool deleted successfully"}, + 401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"}, + 404: {"model": ErrorResponse, "description": "The requested pool does not exist"}, + 501: {"model": ErrorResponse, "description": "Pool management is not supported in this runtime"}, + 500: {"model": ErrorResponse, "description": "An unexpected server error occurred"}, + }, +) +async def delete_pool( + pool_name: str, + x_request_id: Optional[str] = Header(None, alias="X-Request-ID"), +) -> Response: + """ + Delete a pool. + + Removes the Pool CRD resource. Pre-warmed pods managed by the pool will + be terminated by the pool controller. + + Args: + pool_name: Name of the pool to delete. + x_request_id: Optional request tracing identifier. + + Returns: + Response: 204 No Content. + """ + pool_service = _get_pool_service() + pool_service.delete_pool(pool_name) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/src/api/schema.py b/server/src/api/schema.py index c6373dd0..5c557218 100644 --- a/server/src/api/schema.py +++ b/server/src/api/schema.py @@ -472,3 +472,128 @@ class ErrorResponse(BaseModel): ..., description="Human-readable error message describing what went wrong and how to fix it", ) + + +# ============================================================================ +# Pool Models +# ============================================================================ + +class PoolCapacitySpec(BaseModel): + """ + Capacity configuration that controls the size of the resource pool. + """ + buffer_max: int = Field( + ..., + alias="bufferMax", + ge=0, + description="Maximum number of nodes kept in the warm buffer.", + ) + buffer_min: int = Field( + ..., + alias="bufferMin", + ge=0, + description="Minimum number of nodes that must remain in the buffer.", + ) + pool_max: int = Field( + ..., + alias="poolMax", + ge=0, + description="Maximum total number of nodes allowed in the entire pool.", + ) + pool_min: int = Field( + ..., + alias="poolMin", + ge=0, + description="Minimum total size of the pool.", + ) + + class Config: + populate_by_name = True + + +class CreatePoolRequest(BaseModel): + """ + Request to create a new pre-warmed resource pool. + + A Pool manages a set of pre-warmed pods that can be rapidly allocated + to sandboxes, reducing cold-start latency. + """ + 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 = Field( + ..., + description=( + "Kubernetes PodTemplateSpec defining the pod configuration for pre-warmed nodes. " + "Follows the same schema as spec.template in a Kubernetes Deployment." + ), + ) + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="Capacity configuration controlling pool size and buffer behavior.", + ) + + class Config: + populate_by_name = True + + +class UpdatePoolRequest(BaseModel): + """ + Request to update an existing pool's capacity configuration. + + Only capacity settings can be updated after pool creation. + Updating the pod template requires recreating the pool. + """ + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="New capacity configuration for the pool.", + ) + + class Config: + populate_by_name = True + + +class PoolStatus(BaseModel): + """ + Observed runtime state of a pool. + """ + total: int = Field(..., description="Total number of nodes in the pool.") + allocated: int = Field(..., description="Number of nodes currently allocated to sandboxes.") + available: int = Field(..., description="Number of nodes currently available in the pool.") + revision: str = Field(..., description="Latest revision identifier of the pool.") + + +class PoolResponse(BaseModel): + """ + Full representation of a Pool resource. + """ + name: str = Field(..., description="Unique pool name.") + capacity_spec: PoolCapacitySpec = Field( + ..., + alias="capacitySpec", + description="Capacity configuration of the pool.", + ) + status: Optional[PoolStatus] = Field( + None, + description="Observed runtime state of the pool. May be absent if not yet reconciled.", + ) + created_at: Optional[datetime] = Field( + None, + alias="createdAt", + description="Pool creation timestamp.", + ) + + class Config: + populate_by_name = True + + +class ListPoolsResponse(BaseModel): + """ + Collection of pools. + """ + items: List[PoolResponse] = Field(..., description="List of pools.") diff --git a/server/src/main.py b/server/src/main.py index bfdff999..11f054ea 100644 --- a/server/src/main.py +++ b/server/src/main.py @@ -70,6 +70,7 @@ ) from src.api.lifecycle import router # noqa: E402 +from src.api.pool import router as pool_router # noqa: E402 from src.middleware.auth import AuthMiddleware # noqa: E402 from src.middleware.request_id import RequestIdMiddleware # noqa: E402 from src.services.runtime_resolver import ( # noqa: E402 @@ -149,6 +150,8 @@ async def lifespan(app: FastAPI): # Include API routes at root and versioned prefix app.include_router(router) app.include_router(router, prefix="/v1") +app.include_router(pool_router) +app.include_router(pool_router, prefix="/v1") DEFAULT_ERROR_CODE = "GENERAL::UNKNOWN_ERROR" DEFAULT_ERROR_MESSAGE = "An unexpected error occurred." diff --git a/server/src/services/constants.py b/server/src/services/constants.py index 8951b371..a56835c4 100644 --- a/server/src/services/constants.py +++ b/server/src/services/constants.py @@ -58,6 +58,12 @@ class SandboxErrorCodes: INVALID_METADATA_LABEL = "SANDBOX::INVALID_METADATA_LABEL" INVALID_PARAMETER = "SANDBOX::INVALID_PARAMETER" + # Pool error codes + K8S_POOL_NOT_FOUND = "KUBERNETES::POOL_NOT_FOUND" + K8S_POOL_ALREADY_EXISTS = "KUBERNETES::POOL_ALREADY_EXISTS" + K8S_POOL_API_ERROR = "KUBERNETES::POOL_API_ERROR" + K8S_POOL_NOT_SUPPORTED = "KUBERNETES::POOL_NOT_SUPPORTED" + # Volume error codes INVALID_VOLUME_NAME = "VOLUME::INVALID_NAME" DUPLICATE_VOLUME_NAME = "VOLUME::DUPLICATE_NAME" diff --git a/server/src/services/k8s/pool_service.py b/server/src/services/k8s/pool_service.py new file mode 100644 index 00000000..e6cffb70 --- /dev/null +++ b/server/src/services/k8s/pool_service.py @@ -0,0 +1,406 @@ +# 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. + +""" +Kubernetes Pool service for managing pre-warmed sandbox resource pools. + +This module provides CRUD operations for Pool CRD resources, which represent +pre-warmed sets of pods that reduce sandbox cold-start latency. +""" + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException, status +from kubernetes.client import ApiException + +from src.api.schema import ( + CreatePoolRequest, + ListPoolsResponse, + PoolCapacitySpec, + PoolResponse, + PoolStatus, + UpdatePoolRequest, +) +from src.services.constants import SandboxErrorCodes +from src.services.k8s.client import K8sClient + +logger = logging.getLogger(__name__) + +# Pool CRD constants +_GROUP = "sandbox.opensandbox.io" +_VERSION = "v1alpha1" +_PLURAL = "pools" + + +class PoolService: + """ + Service for managing Pool CRD resources in Kubernetes. + + Provides CRUD operations that mirror the Pool CRD schema defined in + kubernetes/apis/sandbox/v1alpha1/pool_types.go. + """ + + def __init__(self, k8s_client: K8sClient, namespace: str) -> None: + """ + Initialize PoolService. + + Args: + k8s_client: Kubernetes client wrapper. + namespace: Kubernetes namespace where pools are managed. + """ + self._custom_api = k8s_client.get_custom_objects_api() + self._namespace = namespace + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_pool_manifest( + self, + name: str, + namespace: str, + template: Dict[str, Any], + capacity_spec: PoolCapacitySpec, + ) -> Dict[str, Any]: + """Build a Pool CRD manifest dict.""" + return { + "apiVersion": f"{_GROUP}/{_VERSION}", + "kind": "Pool", + "metadata": { + "name": name, + "namespace": namespace, + }, + "spec": { + "template": template, + "capacitySpec": { + "bufferMax": capacity_spec.buffer_max, + "bufferMin": capacity_spec.buffer_min, + "poolMax": capacity_spec.pool_max, + "poolMin": capacity_spec.pool_min, + }, + }, + } + + def _pool_from_raw(self, raw: Dict[str, Any]) -> PoolResponse: + """Convert a raw Pool CRD dict to a PoolResponse model.""" + metadata = raw.get("metadata", {}) + spec = raw.get("spec", {}) + raw_status = raw.get("status") + + capacity = spec.get("capacitySpec", {}) + capacity_spec = PoolCapacitySpec( + bufferMax=capacity.get("bufferMax", 0), + bufferMin=capacity.get("bufferMin", 0), + poolMax=capacity.get("poolMax", 0), + poolMin=capacity.get("poolMin", 0), + ) + + pool_status: Optional[PoolStatus] = None + if raw_status: + pool_status = PoolStatus( + total=raw_status.get("total", 0), + allocated=raw_status.get("allocated", 0), + available=raw_status.get("available", 0), + revision=raw_status.get("revision", ""), + ) + + return PoolResponse( + name=metadata.get("name", ""), + capacitySpec=capacity_spec, + status=pool_status, + createdAt=metadata.get("creationTimestamp"), + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_pool(self, request: CreatePoolRequest) -> PoolResponse: + """ + Create a new Pool resource. + + Args: + request: Pool creation request. + + Returns: + PoolResponse representing the newly created pool. + + Raises: + HTTPException 409: If a pool with the same name already exists. + HTTPException 500: On unexpected Kubernetes API errors. + """ + manifest = self._build_pool_manifest( + name=request.name, + namespace=self._namespace, + template=request.template, + capacity_spec=request.capacity_spec, + ) + + try: + created = self._custom_api.create_namespaced_custom_object( + group=_GROUP, + version=_VERSION, + namespace=self._namespace, + plural=_PLURAL, + body=manifest, + ) + logger.info("Created pool: name=%s, namespace=%s", request.name, self._namespace) + return self._pool_from_raw(created) + + except ApiException as e: + if e.status == 409: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS, + "message": f"Pool '{request.name}' already exists.", + }, + ) from e + logger.error("Kubernetes API error creating pool %s: %s", request.name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to create pool: {e.reason}", + }, + ) from e + except Exception as e: + logger.error("Unexpected error creating pool %s: %s", request.name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to create pool: {e}", + }, + ) from e + + def get_pool(self, pool_name: str) -> PoolResponse: + """ + Retrieve a Pool by name. + + Args: + pool_name: Name of the pool to retrieve. + + Returns: + PoolResponse for the requested pool. + + Raises: + HTTPException 404: If the pool does not exist. + HTTPException 500: On unexpected Kubernetes API errors. + """ + try: + raw = self._custom_api.get_namespaced_custom_object( + group=_GROUP, + version=_VERSION, + namespace=self._namespace, + plural=_PLURAL, + name=pool_name, + ) + return self._pool_from_raw(raw) + + except ApiException as e: + if e.status == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": f"Pool '{pool_name}' not found.", + }, + ) from e + logger.error("Kubernetes API error getting pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to get pool: {e.reason}", + }, + ) from e + except HTTPException: + raise + except Exception as e: + logger.error("Unexpected error getting pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to get pool: {e}", + }, + ) from e + + def list_pools(self) -> ListPoolsResponse: + """ + List all Pools in the configured namespace. + + Returns: + ListPoolsResponse containing all pools. + + Raises: + HTTPException 500: On unexpected Kubernetes API errors. + """ + try: + result = self._custom_api.list_namespaced_custom_object( + group=_GROUP, + version=_VERSION, + namespace=self._namespace, + plural=_PLURAL, + ) + items: List[PoolResponse] = [ + self._pool_from_raw(item) for item in result.get("items", []) + ] + return ListPoolsResponse(items=items) + + except ApiException as e: + if e.status == 404: + # CRD not installed — return empty list gracefully + logger.warning("Pool CRD not found (404); returning empty list.") + return ListPoolsResponse(items=[]) + logger.error("Kubernetes API error listing pools: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to list pools: {e.reason}", + }, + ) from e + except Exception as e: + logger.error("Unexpected error listing pools: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to list pools: {e}", + }, + ) from e + + def update_pool(self, pool_name: str, request: UpdatePoolRequest) -> PoolResponse: + """ + Update the capacity configuration of an existing Pool. + + Only ``capacitySpec`` can be updated; pod template changes require + recreating the pool. + + Args: + pool_name: Name of the pool to update. + request: Update request containing the new capacity spec. + + Returns: + PoolResponse reflecting the updated state. + + Raises: + HTTPException 404: If the pool does not exist. + HTTPException 500: On unexpected Kubernetes API errors. + """ + patch_body = { + "spec": { + "capacitySpec": { + "bufferMax": request.capacity_spec.buffer_max, + "bufferMin": request.capacity_spec.buffer_min, + "poolMax": request.capacity_spec.pool_max, + "poolMin": request.capacity_spec.pool_min, + } + } + } + + try: + updated = self._custom_api.patch_namespaced_custom_object( + group=_GROUP, + version=_VERSION, + namespace=self._namespace, + plural=_PLURAL, + name=pool_name, + body=patch_body, + ) + logger.info("Updated pool capacity: name=%s", pool_name) + return self._pool_from_raw(updated) + + except ApiException as e: + if e.status == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": f"Pool '{pool_name}' not found.", + }, + ) from e + logger.error("Kubernetes API error updating pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to update pool: {e.reason}", + }, + ) from e + except HTTPException: + raise + except Exception as e: + logger.error("Unexpected error updating pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to update pool: {e}", + }, + ) from e + + def delete_pool(self, pool_name: str) -> None: + """ + Delete a Pool resource. + + Args: + pool_name: Name of the pool to delete. + + Raises: + HTTPException 404: If the pool does not exist. + HTTPException 500: On unexpected Kubernetes API errors. + """ + try: + self._custom_api.delete_namespaced_custom_object( + group=_GROUP, + version=_VERSION, + namespace=self._namespace, + plural=_PLURAL, + name=pool_name, + grace_period_seconds=0, + ) + logger.info("Deleted pool: name=%s, namespace=%s", pool_name, self._namespace) + + except ApiException as e: + if e.status == 404: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": f"Pool '{pool_name}' not found.", + }, + ) from e + logger.error("Kubernetes API error deleting pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to delete pool: {e.reason}", + }, + ) from e + except HTTPException: + raise + except Exception as e: + logger.error("Unexpected error deleting pool %s: %s", pool_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": f"Failed to delete pool: {e}", + }, + ) from e diff --git a/server/tests/k8s/test_pool_service.py b/server/tests/k8s/test_pool_service.py new file mode 100644 index 00000000..32f6e09d --- /dev/null +++ b/server/tests/k8s/test_pool_service.py @@ -0,0 +1,491 @@ +# 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. + +""" +Unit tests for PoolService (server/src/services/k8s/pool_service.py). + +All tests mock the Kubernetes CustomObjectsApi so no cluster connection is needed. +""" + +import pytest +from unittest.mock import MagicMock, call +from kubernetes.client import ApiException + +from src.api.schema import ( + CreatePoolRequest, + PoolCapacitySpec, + UpdatePoolRequest, +) +from src.services.constants import SandboxErrorCodes +from src.services.k8s.pool_service import PoolService +from fastapi import HTTPException + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_raw_pool( + name: str = "my-pool", + namespace: str = "test-ns", + buffer_max: int = 3, + buffer_min: int = 1, + pool_max: int = 10, + pool_min: int = 0, + total: int = 2, + allocated: int = 1, + available: int = 1, + revision: str = "abc123", +) -> dict: + """Return a fake Pool CRD dict as returned by the Kubernetes API.""" + return { + "apiVersion": "sandbox.opensandbox.io/v1alpha1", + "kind": "Pool", + "metadata": { + "name": name, + "namespace": namespace, + "creationTimestamp": "2025-01-01T00:00:00Z", + }, + "spec": { + "capacitySpec": { + "bufferMax": buffer_max, + "bufferMin": buffer_min, + "poolMax": pool_max, + "poolMin": pool_min, + }, + "template": {"metadata": {}, "spec": {"containers": []}}, + }, + "status": { + "total": total, + "allocated": allocated, + "available": available, + "revision": revision, + }, + } + + +def _make_pool_service(namespace: str = "test-ns") -> tuple[PoolService, MagicMock]: + """Return a (PoolService, mock_custom_api) pair.""" + mock_client = MagicMock() + mock_api = MagicMock() + mock_client.get_custom_objects_api.return_value = mock_api + service = PoolService(mock_client, namespace=namespace) + return service, mock_api + + +def _capacity_spec( + buffer_max: int = 3, + buffer_min: int = 1, + pool_max: int = 10, + pool_min: int = 0, +) -> PoolCapacitySpec: + return PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ) + + +# --------------------------------------------------------------------------- +# _pool_from_raw +# --------------------------------------------------------------------------- + +class TestPoolFromRaw: + def test_full_pool_with_status(self): + svc, _ = _make_pool_service() + raw = _make_raw_pool() + result = svc._pool_from_raw(raw) + + assert result.name == "my-pool" + assert result.capacity_spec.buffer_max == 3 + assert result.capacity_spec.buffer_min == 1 + assert result.capacity_spec.pool_max == 10 + assert result.capacity_spec.pool_min == 0 + assert result.status is not None + assert result.status.total == 2 + assert result.status.allocated == 1 + assert result.status.available == 1 + assert result.status.revision == "abc123" + + def test_pool_without_status(self): + svc, _ = _make_pool_service() + raw = _make_raw_pool() + del raw["status"] + result = svc._pool_from_raw(raw) + + assert result.status is None + + def test_pool_with_empty_status(self): + """status key present but empty dict – treat as no status.""" + svc, _ = _make_pool_service() + raw = _make_raw_pool() + raw["status"] = {} + result = svc._pool_from_raw(raw) + # Empty dict is falsy – status should be None + assert result.status is None + + def test_pool_capacity_defaults_to_zero_on_missing_fields(self): + svc, _ = _make_pool_service() + raw = { + "metadata": {"name": "sparse-pool"}, + "spec": {"capacitySpec": {}}, + } + result = svc._pool_from_raw(raw) + assert result.capacity_spec.buffer_max == 0 + assert result.capacity_spec.pool_max == 0 + + +# --------------------------------------------------------------------------- +# create_pool +# --------------------------------------------------------------------------- + +class TestCreatePool: + def test_create_pool_calls_k8s_api_with_correct_manifest(self): + svc, mock_api = _make_pool_service(namespace="opensandbox") + raw = _make_raw_pool(name="ci-pool", namespace="opensandbox") + mock_api.create_namespaced_custom_object.return_value = raw + + request = CreatePoolRequest( + name="ci-pool", + template={"spec": {"containers": []}}, + capacitySpec=_capacity_spec(), + ) + result = svc.create_pool(request) + + mock_api.create_namespaced_custom_object.assert_called_once() + call_kwargs = mock_api.create_namespaced_custom_object.call_args.kwargs + assert call_kwargs["group"] == "sandbox.opensandbox.io" + assert call_kwargs["version"] == "v1alpha1" + assert call_kwargs["plural"] == "pools" + assert call_kwargs["namespace"] == "opensandbox" + + body = call_kwargs["body"] + assert body["kind"] == "Pool" + assert body["metadata"]["name"] == "ci-pool" + assert body["spec"]["capacitySpec"]["bufferMax"] == 3 + assert body["spec"]["capacitySpec"]["poolMax"] == 10 + + assert result.name == "ci-pool" + + def test_create_pool_returns_pool_response(self): + svc, mock_api = _make_pool_service() + raw = _make_raw_pool() + mock_api.create_namespaced_custom_object.return_value = raw + + request = CreatePoolRequest( + name="my-pool", + template={}, + capacitySpec=_capacity_spec(), + ) + result = svc.create_pool(request) + + assert result.name == "my-pool" + assert result.status is not None + assert result.status.total == 2 + + def test_create_pool_409_raises_http_conflict(self): + svc, mock_api = _make_pool_service() + mock_api.create_namespaced_custom_object.side_effect = ApiException(status=409) + + request = CreatePoolRequest( + name="dup-pool", + template={}, + capacitySpec=_capacity_spec(), + ) + with pytest.raises(HTTPException) as exc_info: + svc.create_pool(request) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS + + def test_create_pool_5xx_api_error_raises_http_500(self): + svc, mock_api = _make_pool_service() + err = ApiException(status=500) + err.reason = "Internal Server Error" + mock_api.create_namespaced_custom_object.side_effect = err + + request = CreatePoolRequest(name="p", template={}, capacitySpec=_capacity_spec()) + with pytest.raises(HTTPException) as exc_info: + svc.create_pool(request) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + def test_create_pool_unexpected_exception_raises_http_500(self): + svc, mock_api = _make_pool_service() + mock_api.create_namespaced_custom_object.side_effect = RuntimeError("boom") + + request = CreatePoolRequest(name="p", template={}, capacitySpec=_capacity_spec()) + with pytest.raises(HTTPException) as exc_info: + svc.create_pool(request) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + +# --------------------------------------------------------------------------- +# get_pool +# --------------------------------------------------------------------------- + +class TestGetPool: + def test_get_pool_returns_correct_pool(self): + svc, mock_api = _make_pool_service() + raw = _make_raw_pool(name="target-pool") + mock_api.get_namespaced_custom_object.return_value = raw + + result = svc.get_pool("target-pool") + + mock_api.get_namespaced_custom_object.assert_called_once_with( + group="sandbox.opensandbox.io", + version="v1alpha1", + namespace="test-ns", + plural="pools", + name="target-pool", + ) + assert result.name == "target-pool" + + def test_get_pool_404_raises_http_not_found(self): + svc, mock_api = _make_pool_service() + mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404) + + with pytest.raises(HTTPException) as exc_info: + svc.get_pool("ghost-pool") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_NOT_FOUND + assert "ghost-pool" in exc_info.value.detail["message"] + + def test_get_pool_5xx_raises_http_500(self): + svc, mock_api = _make_pool_service() + err = ApiException(status=503) + err.reason = "Service Unavailable" + mock_api.get_namespaced_custom_object.side_effect = err + + with pytest.raises(HTTPException) as exc_info: + svc.get_pool("p") + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + def test_get_pool_unexpected_exception_raises_http_500(self): + svc, mock_api = _make_pool_service() + mock_api.get_namespaced_custom_object.side_effect = ConnectionError("timeout") + + with pytest.raises(HTTPException) as exc_info: + svc.get_pool("p") + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# list_pools +# --------------------------------------------------------------------------- + +class TestListPools: + def test_list_pools_returns_all_items(self): + svc, mock_api = _make_pool_service() + mock_api.list_namespaced_custom_object.return_value = { + "items": [ + _make_raw_pool(name="pool-a"), + _make_raw_pool(name="pool-b"), + ] + } + + result = svc.list_pools() + + assert len(result.items) == 2 + names = {p.name for p in result.items} + assert names == {"pool-a", "pool-b"} + + def test_list_pools_empty_returns_empty_list(self): + svc, mock_api = _make_pool_service() + mock_api.list_namespaced_custom_object.return_value = {"items": []} + + result = svc.list_pools() + assert result.items == [] + + def test_list_pools_404_crd_not_installed_returns_empty(self): + """If the CRD doesn't exist (404), list should return empty gracefully.""" + svc, mock_api = _make_pool_service() + mock_api.list_namespaced_custom_object.side_effect = ApiException(status=404) + + result = svc.list_pools() + assert result.items == [] + + def test_list_pools_5xx_raises_http_500(self): + svc, mock_api = _make_pool_service() + err = ApiException(status=500) + err.reason = "Internal" + mock_api.list_namespaced_custom_object.side_effect = err + + with pytest.raises(HTTPException) as exc_info: + svc.list_pools() + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + def test_list_pools_unexpected_exception_raises_http_500(self): + svc, mock_api = _make_pool_service() + mock_api.list_namespaced_custom_object.side_effect = RuntimeError("unexpected") + + with pytest.raises(HTTPException) as exc_info: + svc.list_pools() + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# update_pool +# --------------------------------------------------------------------------- + +class TestUpdatePool: + def test_update_pool_sends_correct_patch(self): + svc, mock_api = _make_pool_service() + updated_raw = _make_raw_pool(buffer_max=5, pool_max=20) + mock_api.patch_namespaced_custom_object.return_value = updated_raw + + request = UpdatePoolRequest(capacitySpec=_capacity_spec(buffer_max=5, pool_max=20)) + result = svc.update_pool("my-pool", request) + + mock_api.patch_namespaced_custom_object.assert_called_once() + call_kwargs = mock_api.patch_namespaced_custom_object.call_args.kwargs + assert call_kwargs["name"] == "my-pool" + assert call_kwargs["namespace"] == "test-ns" + patch_body = call_kwargs["body"] + assert patch_body["spec"]["capacitySpec"]["bufferMax"] == 5 + assert patch_body["spec"]["capacitySpec"]["poolMax"] == 20 + assert result.capacity_spec.buffer_max == 5 + + def test_update_pool_404_raises_http_not_found(self): + svc, mock_api = _make_pool_service() + mock_api.patch_namespaced_custom_object.side_effect = ApiException(status=404) + + with pytest.raises(HTTPException) as exc_info: + svc.update_pool("missing", UpdatePoolRequest(capacitySpec=_capacity_spec())) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_NOT_FOUND + + def test_update_pool_5xx_raises_http_500(self): + svc, mock_api = _make_pool_service() + err = ApiException(status=500) + err.reason = "Timeout" + mock_api.patch_namespaced_custom_object.side_effect = err + + with pytest.raises(HTTPException) as exc_info: + svc.update_pool("p", UpdatePoolRequest(capacitySpec=_capacity_spec())) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + def test_update_pool_unexpected_exception_raises_http_500(self): + svc, mock_api = _make_pool_service() + mock_api.patch_namespaced_custom_object.side_effect = ValueError("bad") + + with pytest.raises(HTTPException) as exc_info: + svc.update_pool("p", UpdatePoolRequest(capacitySpec=_capacity_spec())) + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# delete_pool +# --------------------------------------------------------------------------- + +class TestDeletePool: + def test_delete_pool_calls_k8s_delete(self): + svc, mock_api = _make_pool_service(namespace="opensandbox") + mock_api.delete_namespaced_custom_object.return_value = {} + + svc.delete_pool("old-pool") + + mock_api.delete_namespaced_custom_object.assert_called_once_with( + group="sandbox.opensandbox.io", + version="v1alpha1", + namespace="opensandbox", + plural="pools", + name="old-pool", + grace_period_seconds=0, + ) + + def test_delete_pool_returns_none(self): + svc, mock_api = _make_pool_service() + mock_api.delete_namespaced_custom_object.return_value = {} + + result = svc.delete_pool("p") + assert result is None + + def test_delete_pool_404_raises_http_not_found(self): + svc, mock_api = _make_pool_service() + mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=404) + + with pytest.raises(HTTPException) as exc_info: + svc.delete_pool("ghost") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_NOT_FOUND + assert "ghost" in exc_info.value.detail["message"] + + def test_delete_pool_5xx_raises_http_500(self): + svc, mock_api = _make_pool_service() + err = ApiException(status=500) + err.reason = "Internal" + mock_api.delete_namespaced_custom_object.side_effect = err + + with pytest.raises(HTTPException) as exc_info: + svc.delete_pool("p") + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_POOL_API_ERROR + + def test_delete_pool_unexpected_exception_raises_http_500(self): + svc, mock_api = _make_pool_service() + mock_api.delete_namespaced_custom_object.side_effect = OSError("io") + + with pytest.raises(HTTPException) as exc_info: + svc.delete_pool("p") + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# _build_pool_manifest +# --------------------------------------------------------------------------- + +class TestBuildPoolManifest: + def test_manifest_has_correct_structure(self): + svc, _ = _make_pool_service(namespace="prod") + template = {"spec": {"containers": [{"name": "sandbox"}]}} + cap = _capacity_spec(buffer_max=2, buffer_min=1, pool_max=8, pool_min=0) + + manifest = svc._build_pool_manifest("prod-pool", "prod", template, cap) + + assert manifest["apiVersion"] == "sandbox.opensandbox.io/v1alpha1" + assert manifest["kind"] == "Pool" + assert manifest["metadata"]["name"] == "prod-pool" + assert manifest["metadata"]["namespace"] == "prod" + assert manifest["spec"]["capacitySpec"]["bufferMax"] == 2 + assert manifest["spec"]["capacitySpec"]["poolMin"] == 0 + assert manifest["spec"]["template"] == template + + def test_manifest_capacity_values_are_exact(self): + svc, _ = _make_pool_service() + cap = _capacity_spec(buffer_max=99, buffer_min=7, pool_max=200, pool_min=5) + manifest = svc._build_pool_manifest("p", "ns", {}, cap) + cs = manifest["spec"]["capacitySpec"] + assert cs["bufferMax"] == 99 + assert cs["bufferMin"] == 7 + assert cs["poolMax"] == 200 + assert cs["poolMin"] == 5 diff --git a/server/tests/test_pool_api.py b/server/tests/test_pool_api.py new file mode 100644 index 00000000..3917ee43 --- /dev/null +++ b/server/tests/test_pool_api.py @@ -0,0 +1,496 @@ +# 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. + +""" +Integration-style tests for Pool API routes (src/api/pool.py). + +Routes are exercised via FastAPI TestClient. The K8s PoolService is patched +so no real cluster connection is needed. +""" + +import pytest +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient +from fastapi import HTTPException, status as http_status + +from src.api.schema import ( + CreatePoolRequest, + ListPoolsResponse, + PoolCapacitySpec, + PoolResponse, + PoolStatus, + UpdatePoolRequest, +) +from src.services.constants import SandboxErrorCodes + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +_POOL_SERVICE_PATCH = "src.api.pool._get_pool_service" + + +def _cap(buffer_max=3, buffer_min=1, pool_max=10, pool_min=0) -> PoolCapacitySpec: + return PoolCapacitySpec( + bufferMax=buffer_max, + bufferMin=buffer_min, + poolMax=pool_max, + poolMin=pool_min, + ) + + +def _pool_response( + name: str = "test-pool", + buffer_max: int = 3, + pool_max: int = 10, + total: int = 2, + allocated: int = 1, + available: int = 1, +) -> PoolResponse: + return PoolResponse( + name=name, + capacitySpec=_cap(buffer_max=buffer_max, pool_max=pool_max), + status=PoolStatus( + total=total, + allocated=allocated, + available=available, + revision="rev-1", + ), + ) + + +def _create_request_body(name: str = "test-pool") -> dict: + return { + "name": name, + "template": { + "spec": { + "containers": [ + { + "name": "sandbox", + "image": "python:3.11", + "command": ["tail", "-f", "/dev/null"], + } + ] + } + }, + "capacitySpec": { + "bufferMax": 3, + "bufferMin": 1, + "poolMax": 10, + "poolMin": 0, + }, + } + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +class TestPoolAuthentication: + def test_list_pools_without_api_key_returns_401(self, client: TestClient): + response = client.get("/pools") + assert response.status_code == 401 + + def test_create_pool_without_api_key_returns_401(self, client: TestClient): + response = client.post("/pools", json=_create_request_body()) + assert response.status_code == 401 + + def test_get_pool_without_api_key_returns_401(self, client: TestClient): + response = client.get("/pools/my-pool") + assert response.status_code == 401 + + def test_update_pool_without_api_key_returns_401(self, client: TestClient): + response = client.put( + "/pools/my-pool", + json={"capacitySpec": {"bufferMax": 5, "bufferMin": 1, "poolMax": 10, "poolMin": 0}}, + ) + assert response.status_code == 401 + + def test_delete_pool_without_api_key_returns_401(self, client: TestClient): + response = client.delete("/pools/my-pool") + assert response.status_code == 401 + + def test_pool_routes_exist_on_v1_prefix(self, client: TestClient, auth_headers: dict): + """Verify the /v1/pools routes are registered (even if they return 501 on docker runtime).""" + with patch(_POOL_SERVICE_PATCH) as mock_svc_factory: + mock_svc_factory.side_effect = HTTPException( + status_code=http_status.HTTP_501_NOT_IMPLEMENTED, + detail={"code": "X", "message": "y"}, + ) + response = client.get("/v1/pools", headers=auth_headers) + assert response.status_code == 501 + + +# --------------------------------------------------------------------------- +# 501 – non-Kubernetes runtime +# --------------------------------------------------------------------------- + +class TestPoolNotSupportedOnDockerRuntime: + """Pool endpoints return 501 when PoolService raises 501 (non-k8s runtime).""" + + def _mock_not_supported(self): + svc = MagicMock() + svc.side_effect = HTTPException( + status_code=http_status.HTTP_501_NOT_IMPLEMENTED, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, + "message": "Pool management is only available when runtime.type is 'kubernetes'.", + }, + ) + return svc + + def test_list_pools_returns_501(self, client: TestClient, auth_headers: dict): + with patch(_POOL_SERVICE_PATCH, side_effect=HTTPException( + status_code=501, + detail={"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, "message": "not k8s"}, + )): + response = client.get("/pools", headers=auth_headers) + assert response.status_code == 501 + assert SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED in response.json()["code"] + + def test_create_pool_returns_501(self, client: TestClient, auth_headers: dict): + with patch(_POOL_SERVICE_PATCH, side_effect=HTTPException( + status_code=501, + detail={"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED, "message": "not k8s"}, + )): + response = client.post("/pools", json=_create_request_body(), headers=auth_headers) + assert response.status_code == 501 + + +# --------------------------------------------------------------------------- +# POST /pools +# --------------------------------------------------------------------------- + +class TestCreatePoolRoute: + def test_create_pool_success_returns_201(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.create_pool.return_value = _pool_response(name="new-pool") + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.post("/pools", json=_create_request_body("new-pool"), headers=auth_headers) + + assert response.status_code == 201 + body = response.json() + assert body["name"] == "new-pool" + assert body["capacitySpec"]["bufferMax"] == 3 + assert body["status"]["total"] == 2 + + def test_create_pool_missing_name_returns_422(self, client: TestClient, auth_headers: dict): + body = _create_request_body() + del body["name"] + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.post("/pools", json=body, headers=auth_headers) + assert response.status_code == 422 + + def test_create_pool_missing_template_returns_422(self, client: TestClient, auth_headers: dict): + body = _create_request_body() + del body["template"] + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.post("/pools", json=body, headers=auth_headers) + assert response.status_code == 422 + + def test_create_pool_missing_capacity_spec_returns_422(self, client: TestClient, auth_headers: dict): + body = _create_request_body() + del body["capacitySpec"] + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.post("/pools", json=body, headers=auth_headers) + assert response.status_code == 422 + + def test_create_pool_invalid_name_pattern_returns_422(self, client: TestClient, auth_headers: dict): + """Pool name must be a valid k8s name (no uppercase, no spaces).""" + body = _create_request_body("Invalid_Name") + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.post("/pools", json=body, headers=auth_headers) + assert response.status_code == 422 + + def test_create_pool_negative_buffer_max_returns_422(self, client: TestClient, auth_headers: dict): + body = _create_request_body() + body["capacitySpec"]["bufferMax"] = -1 + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.post("/pools", json=body, headers=auth_headers) + assert response.status_code == 422 + + def test_create_pool_duplicate_returns_409(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.create_pool.side_effect = HTTPException( + status_code=409, + detail={ + "code": SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS, + "message": "Pool 'dup-pool' already exists.", + }, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.post("/pools", json=_create_request_body("dup-pool"), headers=auth_headers) + + assert response.status_code == 409 + assert SandboxErrorCodes.K8S_POOL_ALREADY_EXISTS in response.json()["code"] + + def test_create_pool_service_error_returns_500(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.create_pool.side_effect = HTTPException( + status_code=500, + detail={ + "code": SandboxErrorCodes.K8S_POOL_API_ERROR, + "message": "k8s api error", + }, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.post("/pools", json=_create_request_body(), headers=auth_headers) + + assert response.status_code == 500 + + def test_create_pool_passes_request_to_service(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.create_pool.return_value = _pool_response() + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + client.post("/pools", json=_create_request_body("my-pool"), headers=auth_headers) + + call_args = mock_svc.create_pool.call_args + req: CreatePoolRequest = call_args.args[0] + assert req.name == "my-pool" + assert req.capacity_spec.buffer_max == 3 + assert req.capacity_spec.pool_max == 10 + + +# --------------------------------------------------------------------------- +# GET /pools +# --------------------------------------------------------------------------- + +class TestListPoolsRoute: + def test_list_pools_returns_200_and_items(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.list_pools.return_value = ListPoolsResponse( + items=[_pool_response("pool-a"), _pool_response("pool-b")] + ) + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools", headers=auth_headers) + + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) == 2 + names = {p["name"] for p in body["items"]} + assert names == {"pool-a", "pool-b"} + + def test_list_pools_empty_returns_200_and_empty_list(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.list_pools.return_value = ListPoolsResponse(items=[]) + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools", headers=auth_headers) + + assert response.status_code == 200 + assert response.json()["items"] == [] + + def test_list_pools_service_error_returns_500(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.list_pools.side_effect = HTTPException( + status_code=500, + detail={"code": SandboxErrorCodes.K8S_POOL_API_ERROR, "message": "err"}, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools", headers=auth_headers) + + assert response.status_code == 500 + + def test_list_pools_response_has_status_fields(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.list_pools.return_value = ListPoolsResponse( + items=[_pool_response("p", total=5, allocated=3, available=2)] + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools", headers=auth_headers) + + pool = response.json()["items"][0] + assert pool["status"]["total"] == 5 + assert pool["status"]["allocated"] == 3 + assert pool["status"]["available"] == 2 + + +# --------------------------------------------------------------------------- +# GET /pools/{pool_name} +# --------------------------------------------------------------------------- + +class TestGetPoolRoute: + def test_get_pool_success_returns_200(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.get_pool.return_value = _pool_response(name="my-pool") + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools/my-pool", headers=auth_headers) + + assert response.status_code == 200 + assert response.json()["name"] == "my-pool" + + def test_get_pool_calls_service_with_correct_name(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.get_pool.return_value = _pool_response() + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + client.get("/pools/target-pool", headers=auth_headers) + + mock_svc.get_pool.assert_called_once_with("target-pool") + + def test_get_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.get_pool.side_effect = HTTPException( + status_code=404, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": "Pool 'ghost' not found.", + }, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools/ghost", headers=auth_headers) + + assert response.status_code == 404 + assert SandboxErrorCodes.K8S_POOL_NOT_FOUND in response.json()["code"] + + def test_get_pool_response_includes_capacity_spec(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.get_pool.return_value = _pool_response(buffer_max=7, pool_max=50) + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.get("/pools/p", headers=auth_headers) + + cap = response.json()["capacitySpec"] + assert cap["bufferMax"] == 7 + assert cap["poolMax"] == 50 + + +# --------------------------------------------------------------------------- +# PUT /pools/{pool_name} +# --------------------------------------------------------------------------- + +class TestUpdatePoolRoute: + def _update_body(self, buffer_max=5, pool_max=20) -> dict: + return { + "capacitySpec": { + "bufferMax": buffer_max, + "bufferMin": 1, + "poolMax": pool_max, + "poolMin": 0, + } + } + + def test_update_pool_success_returns_200(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.update_pool.return_value = _pool_response(buffer_max=5, pool_max=20) + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.put("/pools/my-pool", json=self._update_body(), headers=auth_headers) + + assert response.status_code == 200 + assert response.json()["capacitySpec"]["bufferMax"] == 5 + + def test_update_pool_calls_service_with_name_and_request( + self, client: TestClient, auth_headers: dict + ): + mock_svc = MagicMock() + mock_svc.update_pool.return_value = _pool_response() + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + client.put("/pools/target", json=self._update_body(buffer_max=9), headers=auth_headers) + + call_args = mock_svc.update_pool.call_args + assert call_args.args[0] == "target" + req: UpdatePoolRequest = call_args.args[1] + assert req.capacity_spec.buffer_max == 9 + + def test_update_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.update_pool.side_effect = HTTPException( + status_code=404, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": "Pool 'x' not found.", + }, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.put("/pools/x", json=self._update_body(), headers=auth_headers) + + assert response.status_code == 404 + + def test_update_pool_missing_capacity_spec_returns_422( + self, client: TestClient, auth_headers: dict + ): + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.put("/pools/p", json={}, headers=auth_headers) + assert response.status_code == 422 + + def test_update_pool_negative_pool_max_returns_422( + self, client: TestClient, auth_headers: dict + ): + with patch(_POOL_SERVICE_PATCH, return_value=MagicMock()): + response = client.put( + "/pools/p", + json={"capacitySpec": {"bufferMax": 1, "bufferMin": 0, "poolMax": -5, "poolMin": 0}}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# DELETE /pools/{pool_name} +# --------------------------------------------------------------------------- + +class TestDeletePoolRoute: + def test_delete_pool_success_returns_204(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.delete_pool.return_value = None + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.delete("/pools/my-pool", headers=auth_headers) + + assert response.status_code == 204 + assert response.content == b"" + + def test_delete_pool_calls_service_with_correct_name( + self, client: TestClient, auth_headers: dict + ): + mock_svc = MagicMock() + mock_svc.delete_pool.return_value = None + + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + client.delete("/pools/to-remove", headers=auth_headers) + + mock_svc.delete_pool.assert_called_once_with("to-remove") + + def test_delete_pool_not_found_returns_404(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.delete_pool.side_effect = HTTPException( + status_code=404, + detail={ + "code": SandboxErrorCodes.K8S_POOL_NOT_FOUND, + "message": "Pool 'gone' not found.", + }, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.delete("/pools/gone", headers=auth_headers) + + assert response.status_code == 404 + assert SandboxErrorCodes.K8S_POOL_NOT_FOUND in response.json()["code"] + + def test_delete_pool_service_error_returns_500(self, client: TestClient, auth_headers: dict): + mock_svc = MagicMock() + mock_svc.delete_pool.side_effect = HTTPException( + status_code=500, + detail={"code": SandboxErrorCodes.K8S_POOL_API_ERROR, "message": "err"}, + ) + with patch(_POOL_SERVICE_PATCH, return_value=mock_svc): + response = client.delete("/pools/p", headers=auth_headers) + + assert response.status_code == 500