e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
327 lines
12 KiB
Python
327 lines
12 KiB
Python
# 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 pre-warmed sandbox resource pools."""
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import HTTPException, status
|
|
from kubernetes.client import ApiException
|
|
|
|
from opensandbox_server.api.schema import (
|
|
CreatePoolRequest,
|
|
ListPoolsResponse,
|
|
PoolCapacitySpec,
|
|
PoolResponse,
|
|
PoolStatus,
|
|
UpdatePoolRequest,
|
|
)
|
|
from opensandbox_server.services.constants import SandboxErrorCodes
|
|
from opensandbox_server.services.k8s.client import (
|
|
K8sClient,
|
|
OPENSANDBOX_API_GROUP,
|
|
OPENSANDBOX_API_VERSION,
|
|
POOL_KIND,
|
|
POOL_PLURAL,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PoolService:
|
|
"""Service for managing Pool CRD resources in Kubernetes."""
|
|
|
|
def __init__(self, k8s_client: K8sClient, namespace: str) -> None:
|
|
"""Initialize PoolService."""
|
|
self._custom_api = k8s_client.get_custom_objects_api()
|
|
self._namespace = namespace
|
|
|
|
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"{OPENSANDBOX_API_GROUP}/{OPENSANDBOX_API_VERSION}",
|
|
"kind": POOL_KIND,
|
|
"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"),
|
|
)
|
|
|
|
def create_pool(self, request: CreatePoolRequest) -> PoolResponse:
|
|
"""Create a new Pool resource."""
|
|
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=OPENSANDBOX_API_GROUP,
|
|
version=OPENSANDBOX_API_VERSION,
|
|
namespace=self._namespace,
|
|
plural=POOL_PLURAL,
|
|
body=manifest,
|
|
)
|
|
logger.info(f"Created pool: name={request.name}, namespace={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(f"Kubernetes API error creating pool {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(f"Unexpected error creating pool {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."""
|
|
try:
|
|
raw = self._custom_api.get_namespaced_custom_object(
|
|
group=OPENSANDBOX_API_GROUP,
|
|
version=OPENSANDBOX_API_VERSION,
|
|
namespace=self._namespace,
|
|
plural=POOL_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(f"Kubernetes API error getting pool {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(f"Unexpected error getting pool {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."""
|
|
try:
|
|
result = self._custom_api.list_namespaced_custom_object(
|
|
group=OPENSANDBOX_API_GROUP,
|
|
version=OPENSANDBOX_API_VERSION,
|
|
namespace=self._namespace,
|
|
plural=POOL_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(f"Kubernetes API error listing pools: {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(f"Unexpected error listing pools: {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."""
|
|
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=OPENSANDBOX_API_GROUP,
|
|
version=OPENSANDBOX_API_VERSION,
|
|
namespace=self._namespace,
|
|
plural=POOL_PLURAL,
|
|
name=pool_name,
|
|
body=patch_body,
|
|
)
|
|
logger.info(f"Updated pool capacity: name={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(f"Kubernetes API error updating pool {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(f"Unexpected error updating pool {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."""
|
|
try:
|
|
self._custom_api.delete_namespaced_custom_object(
|
|
group=OPENSANDBOX_API_GROUP,
|
|
version=OPENSANDBOX_API_VERSION,
|
|
namespace=self._namespace,
|
|
plural=POOL_PLURAL,
|
|
name=pool_name,
|
|
grace_period_seconds=0,
|
|
)
|
|
logger.info(f"Deleted pool: name={pool_name}, namespace={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(f"Kubernetes API error deleting pool {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(f"Unexpected error deleting pool {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
|