Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion server/src/api/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@
Sandbox,
SandboxFilter,
)
from src.services.factory import create_sandbox_service
from src.services.factory import create_pool_service, create_sandbox_service
from src.services.k8s.pool_service import PoolService

# Initialize router
router = APIRouter(tags=["Sandboxes"])

# Initialize service based on configuration from config.toml (defaults to docker)
sandbox_service = create_sandbox_service()
warm_pool_service: Optional[PoolService] = create_pool_service()


# ============================================================================
Expand Down
105 changes: 61 additions & 44 deletions server/src/api/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
These endpoints are only available when the runtime is configured as 'kubernetes'.
"""

from typing import Optional

from fastapi import APIRouter, Header, status
from fastapi import APIRouter, Path, status
from fastapi.exceptions import HTTPException
from fastapi.responses import Response

Expand All @@ -32,65 +30,77 @@
PoolResponse,
UpdatePoolRequest,
)
from src.config import get_config
from src.services.constants import SandboxErrorCodes

router = APIRouter(tags=["Pools"])
router = APIRouter(tags=["WarmPools"])

# Align path validation with OpenAPI `WarmPoolName` / Kubernetes DNS subdomain rules.
_POOL_NAME_PATH = Path(
...,
pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
max_length=253,
description="Pool name (Kubernetes metadata.name).",
)

_POOL_NOT_K8S_DETAIL = {
"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED,
"message": "Pool management is only available when runtime.type is 'kubernetes'.",
}

_POOL_BATCHSANDBOX_ONLY_DETAIL = {
"code": SandboxErrorCodes.K8S_POOL_NOT_SUPPORTED,
"message": (
"Pool management is only available when kubernetes.workload_provider is "
"'batchsandbox' (WarmPool is used with BatchSandbox workloads)."
),
}


def _get_pool_service():
"""
Lazily create the PoolService, raising 501 if the runtime is not Kubernetes.
Return the process-wide ``PoolService`` (see ``lifecycle.warm_pool_service``).

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.
Resolved via ``lifecycle.warm_pool_service``, created by ``create_pool_service()``
at import (Kubernetes + ``batchsandbox`` workload provider only). Raises 501 when
WarmPool is not available for the current configuration.
"""
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,
)
from src.api.lifecycle import warm_pool_service

if not config.kubernetes:
if warm_pool_service is None:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=_POOL_NOT_K8S_DETAIL,
detail=_POOL_BATCHSANDBOX_ONLY_DETAIL,
)

k8s_client = K8sClient(config.kubernetes)
return PoolService(k8s_client, namespace=config.kubernetes.namespace)
return warm_pool_service


# ============================================================================
# Pool CRUD Endpoints
# ============================================================================

@router.post(
"/pools",
"/warmpools",
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"},
422: {
"description": (
"Request validation failed: JSON body did not match the schema "
"(FastAPI/Pydantic `detail` array)."
),
},
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.
Expand All @@ -101,7 +111,6 @@ async def create_pool(

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.
Expand All @@ -111,27 +120,26 @@ async def create_pool(


@router.get(
"/pools",
"/warmpools",
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"},
503: {
"model": ErrorResponse,
"description": "Pool list API unavailable (e.g. CRD not installed or wrong API version)",
},
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:
async def list_pools() -> 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.
"""
Expand All @@ -140,27 +148,30 @@ async def list_pools(


@router.get(
"/pools/{pool_name}",
"/warmpools/{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"},
422: {
"description": (
"Path parameter `pool_name` failed validation (invalid Kubernetes resource name pattern)."
),
},
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"),
pool_name: str = _POOL_NAME_PATH,
) -> 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.
Expand All @@ -170,22 +181,26 @@ async def get_pool(


@router.put(
"/pools/{pool_name}",
"/warmpools/{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"},
422: {
"description": (
"Validation error: invalid `pool_name` path and/or request body did not match the schema."
),
},
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"),
pool_name: str = _POOL_NAME_PATH,
) -> PoolResponse:
"""
Update pool capacity configuration.
Expand All @@ -195,9 +210,8 @@ async def update_pool(
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.
pool_name: Name of the pool to update.

Returns:
PoolResponse: Updated pool state.
Expand All @@ -207,19 +221,23 @@ async def update_pool(


@router.delete(
"/pools/{pool_name}",
"/warmpools/{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"},
422: {
"description": (
"Path parameter `pool_name` failed validation (invalid Kubernetes resource name pattern)."
),
},
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"),
pool_name: str = _POOL_NAME_PATH,
) -> Response:
"""
Delete a pool.
Expand All @@ -229,7 +247,6 @@ async def delete_pool(

Args:
pool_name: Name of the pool to delete.
x_request_id: Optional request tracing identifier.

Returns:
Response: 204 No Content.
Expand Down
14 changes: 5 additions & 9 deletions server/src/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from datetime import datetime
from typing import Dict, List, Literal, Optional

from pydantic import BaseModel, Field, RootModel, model_validator
from pydantic import BaseModel, ConfigDict, Field, RootModel, model_validator


# ============================================================================
Expand Down Expand Up @@ -582,8 +582,7 @@ class PoolCapacitySpec(BaseModel):
description="Minimum total size of the pool.",
)

class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)


class CreatePoolRequest(BaseModel):
Expand Down Expand Up @@ -612,8 +611,7 @@ class CreatePoolRequest(BaseModel):
description="Capacity configuration controlling pool size and buffer behavior.",
)

class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)


class UpdatePoolRequest(BaseModel):
Expand All @@ -629,8 +627,7 @@ class UpdatePoolRequest(BaseModel):
description="New capacity configuration for the pool.",
)

class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)


class PoolStatus(BaseModel):
Expand Down Expand Up @@ -663,8 +660,7 @@ class PoolResponse(BaseModel):
description="Pool creation timestamp.",
)

class Config:
populate_by_name = True
model_config = ConfigDict(populate_by_name=True)


class ListPoolsResponse(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@

from src.api.lifecycle import router # noqa: E402
from src.api.pool import router as pool_router # noqa: E402
from src.api.lifecycle import router, sandbox_service # noqa: E402
from src.api.lifecycle import sandbox_service # noqa: E402
from src.api.proxy import router as proxy_router # noqa: E402
from src.integrations.renew_intent.proxy_renew import ProxyRenewCoordinator # noqa: E402
from src.middleware.auth import AuthMiddleware # noqa: E402
Expand Down
2 changes: 2 additions & 0 deletions server/src/services/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ class SandboxErrorCodes:
K8S_POOL_ALREADY_EXISTS = "KUBERNETES::POOL_ALREADY_EXISTS"
K8S_POOL_API_ERROR = "KUBERNETES::POOL_API_ERROR"
K8S_POOL_NOT_SUPPORTED = "KUBERNETES::POOL_NOT_SUPPORTED"
# List pools returned 404 (e.g. CRD/APIGroup not served, wrong version, or namespace API missing)
K8S_POOL_LIST_UNAVAILABLE = "KUBERNETES::POOL_LIST_UNAVAILABLE"

# Volume error codes
INVALID_VOLUME_NAME = "VOLUME::INVALID_NAME"
Expand Down
Loading