Files
opensandbox-group--opensandbox/server/opensandbox_server/api/lifecycle.py
T
wehub-resource-sync e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

576 lines
24 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.
"""
API routes for OpenSandbox Lifecycle API.
This module defines FastAPI routes that map to the OpenAPI specification endpoints.
All business logic is delegated to the service layer that backs each operation.
"""
from typing import List, Optional
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request, status
from fastapi.responses import Response
from opensandbox_server.extensions import validate_extensions
from opensandbox_server.config import get_config
from opensandbox_server.api.schema import (
CreateSnapshotRequest,
CreateSandboxRequest,
CreateSandboxResponse,
Endpoint,
ErrorResponse,
ListSnapshotsRequest,
ListSnapshotsResponse,
ListSandboxesRequest,
ListSandboxesResponse,
PaginationRequest,
PatchSandboxMetadataRequest,
RenewSandboxExpirationRequest,
RenewSandboxExpirationResponse,
Sandbox,
SandboxFilter,
Snapshot,
SnapshotFilter,
)
from opensandbox_server.services.constants import SandboxErrorCodes
from opensandbox_server.services.factory import create_sandbox_service
from opensandbox_server.services.snapshot_service import create_snapshot_service
# Initialize router
router = APIRouter(tags=["Sandboxes"])
# Initialize service based on configuration from config.toml (defaults to docker)
sandbox_service = create_sandbox_service()
snapshot_service = create_snapshot_service(sandbox_service)
# ============================================================================
# Sandbox CRUD Operations
# ============================================================================
@router.post(
"/sandboxes",
response_model=CreateSandboxResponse,
response_model_exclude_none=True,
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {"description": "Sandbox creation accepted for asynchronous provisioning"},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
async def create_sandbox(
request: CreateSandboxRequest,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> CreateSandboxResponse:
"""
Create a sandbox from a container image.
Creates a new sandbox from a container image with optional resource limits,
environment variables, and metadata. Sandboxes are provisioned directly from
the specified image without requiring a pre-created template.
Args:
request: Sandbox creation request
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
CreateSandboxResponse: Accepted sandbox creation request
Raises:
HTTPException: If sandbox creation scheduling fails
"""
validate_extensions(request.extensions)
return await sandbox_service.create_sandbox(request)
# Search endpoint
@router.get(
"/sandboxes",
response_model=ListSandboxesResponse,
response_model_exclude_none=True,
responses={
200: {"description": "Paginated collection of sandboxes"},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def list_sandboxes(
state: Optional[List[str]] = Query(None, description="Filter by lifecycle state. Pass multiple times for OR logic."),
metadata: Optional[str] = Query(None, description="Arbitrary metadata key-value pairs for filtering (URL encoded)."),
page: int = Query(1, ge=1, description="Page number for pagination"),
page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"),
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> ListSandboxesResponse:
"""
List sandboxes with optional filtering and pagination.
List all sandboxes with optional filtering and pagination using query parameters.
All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.
Args:
state: Filter by lifecycle state.
metadata: Arbitrary metadata key-value pairs for filtering.
page: Page number for pagination.
page_size: Number of items per page.
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
ListSandboxesResponse: Paginated list of sandboxes
"""
# Parse metadata query string into dictionary
metadata_dict = {}
if metadata:
from urllib.parse import parse_qsl
try:
# Parse query string format: key=value&key2=value2
# strict_parsing=True rejects malformed segments like "a=1&broken"
parsed = parse_qsl(metadata, keep_blank_values=True, strict_parsing=True)
metadata_dict = dict(parsed)
except Exception as e:
from fastapi import HTTPException
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "INVALID_METADATA_FORMAT", "message": f"Invalid metadata format: {str(e)}"}
)
# Construct request object
request = ListSandboxesRequest(
filter=SandboxFilter(state=state, metadata=metadata_dict if metadata_dict else None),
pagination=PaginationRequest(page=page, pageSize=page_size)
)
import logging
logger = logging.getLogger(__name__)
logger.info("ListSandboxes: %s", request.filter)
# Delegate to the service layer for filtering and pagination
return sandbox_service.list_sandboxes(request)
@router.get(
"/sandboxes/{sandbox_id}",
response_model=Sandbox,
response_model_exclude_none=True,
responses={
200: {"description": "Sandbox current state and metadata"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def get_sandbox(
sandbox_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Sandbox:
"""
Fetch a sandbox by id.
Returns the complete sandbox information including image specification,
status, metadata, and timestamps.
Args:
sandbox_id: Unique sandbox identifier
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
Sandbox: Complete sandbox information
Raises:
HTTPException: If sandbox not found or access denied
"""
# Delegate to the service layer for sandbox lookup
return sandbox_service.get_sandbox(sandbox_id)
@router.patch(
"/sandboxes/{sandbox_id}/metadata",
response_model=Sandbox,
response_model_exclude_none=True,
responses={
200: {"description": "Metadata patched successfully. Returns the complete sandbox with updated metadata."},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def patch_sandbox_metadata(
sandbox_id: str,
patch: PatchSandboxMetadataRequest = Body(...),
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Sandbox:
"""
Patch sandbox metadata via JSON Merge Patch (RFC 7396).
Non-null adds/replaces, null deletes, absent keeps.
Read-modify-write without optimistic locking — concurrent PATCH may drop updates.
"""
return sandbox_service.patch_sandbox_metadata(sandbox_id, patch)
@router.delete(
"/sandboxes/{sandbox_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={
204: {"description": "Sandbox successfully deleted"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def delete_sandbox(
sandbox_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Response:
"""
Delete a sandbox.
Terminates sandbox execution. The sandbox will transition through Stopping state to Terminated.
Args:
sandbox_id: Unique sandbox identifier
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
Response: 204 No Content
Raises:
HTTPException: If sandbox not found or deletion fails
"""
# Delegate to the service layer for deletion
sandbox_service.delete_sandbox(sandbox_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ============================================================================
# Sandbox Lifecycle Operations
# ============================================================================
@router.post(
"/sandboxes/{sandbox_id}/pause",
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {"description": "Pause operation accepted"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def pause_sandbox(
sandbox_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Response:
"""
Pause execution while retaining state.
Pauses a running sandbox while preserving its state.
Poll GET /sandboxes/{sandboxId} to track state transition through Pausing and eventually Paused.
Args:
sandbox_id: Unique sandbox identifier
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
Response: 202 Accepted
Raises:
HTTPException: If sandbox not found or cannot be paused
"""
# Delegate to the service layer for pause orchestration
sandbox_service.pause_sandbox(sandbox_id)
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.post(
"/sandboxes/{sandbox_id}/resume",
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {"description": "Resume operation accepted"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def resume_sandbox(
sandbox_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Response:
"""
Resume a paused sandbox.
Resumes execution of a paused sandbox.
Poll GET /sandboxes/{sandboxId} to track state transition through Resuming and eventually Running.
Args:
sandbox_id: Unique sandbox identifier
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
Response: 202 Accepted
Raises:
HTTPException: If sandbox not found or cannot be resumed
"""
# Delegate to the service layer for resume orchestration
sandbox_service.resume_sandbox(sandbox_id)
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.post(
"/sandboxes/{sandbox_id}/renew-expiration",
response_model=RenewSandboxExpirationResponse,
response_model_exclude_none=True,
responses={
200: {"description": "Sandbox expiration updated successfully"},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def renew_sandbox_expiration(
sandbox_id: str,
request: RenewSandboxExpirationRequest,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> RenewSandboxExpirationResponse:
"""
Renew sandbox expiration.
Renews the absolute expiration time of a sandbox.
The new expiration time must be in the future and after the current expiresAt time.
Args:
sandbox_id: Unique sandbox identifier
request: Renewal request with new expiration time
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
RenewSandboxExpirationResponse: Updated expiration time
Raises:
HTTPException: If sandbox not found or renewal fails
"""
# Delegate to the service layer for expiration updates
return sandbox_service.renew_expiration(sandbox_id, request)
# ============================================================================
# Snapshot Operations
# ============================================================================
@router.post(
"/sandboxes/{sandbox_id}/snapshots",
tags=["Snapshots"],
response_model=Snapshot,
response_model_exclude_none=True,
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {"description": "Snapshot creation accepted"},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def create_snapshot(
sandbox_id: str,
response: Response,
request: Optional[CreateSnapshotRequest] = None,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Snapshot:
"""
Create a persistent point-in-time snapshot from a sandbox.
"""
create_request = request or CreateSnapshotRequest()
snapshot = snapshot_service.create_snapshot(sandbox_id, create_request)
response.headers["Location"] = f"/v1/snapshots/{snapshot.id}"
return snapshot
@router.get(
"/snapshots",
tags=["Snapshots"],
response_model=ListSnapshotsResponse,
response_model_exclude_none=True,
responses={
200: {"description": "Paginated collection of snapshots"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def list_snapshots(
sandbox_id: Optional[str] = Query(None, alias="sandboxId", description="Filter snapshots by source sandbox identifier"),
state: Optional[List[str]] = Query(None, description="Filter by snapshot lifecycle state. Pass multiple times for OR logic."),
page: int = Query(1, ge=1, description="Page number for pagination"),
page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"),
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> ListSnapshotsResponse:
"""
List snapshots with optional filtering and pagination.
"""
request = ListSnapshotsRequest(
filter=SnapshotFilter(sandboxId=sandbox_id, state=state),
pagination=PaginationRequest(page=page, pageSize=page_size),
)
return snapshot_service.list_snapshots(request)
@router.get(
"/snapshots/{snapshot_id}",
tags=["Snapshots"],
response_model=Snapshot,
response_model_exclude_none=True,
responses={
200: {"description": "Snapshot current state and metadata"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def get_snapshot(
snapshot_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Snapshot:
"""
Fetch a snapshot by id.
"""
return snapshot_service.get_snapshot(snapshot_id)
@router.delete(
"/snapshots/{snapshot_id}",
tags=["Snapshots"],
status_code=status.HTTP_204_NO_CONTENT,
responses={
204: {"description": "Snapshot successfully deleted"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
409: {"model": ErrorResponse, "description": "The snapshot is not in a deletable state or is still in use"},
501: {"model": ErrorResponse, "description": "Snapshot management is not implemented yet"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def delete_snapshot(
snapshot_id: str,
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Response:
"""
Delete a snapshot by id.
"""
snapshot_service.delete_snapshot(snapshot_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ============================================================================
# Sandbox Endpoints
# ============================================================================
@router.get(
"/sandboxes/{sandbox_id}/endpoints/{port}",
response_model=Endpoint,
response_model_exclude_none=True,
responses={
200: {"description": "Endpoint retrieved successfully"},
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
403: {"model": ErrorResponse, "description": "The authenticated user lacks permission for this operation"},
404: {"model": ErrorResponse, "description": "The requested resource does not exist"},
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
},
)
def get_sandbox_endpoint(
request: Request,
sandbox_id: str,
port: int,
use_server_proxy: bool = Query(False, description="Whether to return a server-proxied URL"),
expires: Optional[int] = Query(None, description="Request a signed route token with this Unix epoch second expiration. Requires ingress gateway with secure_access configured."),
x_request_id: Optional[str] = Header(None, alias="X-Request-ID", description="Unique request identifier for tracing"),
) -> Endpoint:
"""
Get sandbox access endpoint.
Returns the public access endpoint URL for accessing a service running on a specific port
within the sandbox. The service must be listening on the specified port inside the sandbox
for the endpoint to be available.
When the ``expires`` query parameter is provided, the endpoint is wrapped in a
cryptographically signed route token (OSEP-0011) instead of returning a plain URL.
This requires the ingress gateway to be configured with secure_access signing keys.
Args:
request: FastAPI request object
sandbox_id: Unique sandbox identifier
port: Port number where the service is listening inside the sandbox (1-65535)
use_server_proxy: Whether to return a server-proxied URL
expires: Unix epoch seconds for signed route token expiration. Must be a
non-negative uint64 value. When omitted or invalid, a plain (unsigned)
endpoint is returned.
x_request_id: Unique request identifier for tracing (optional; server generates if omitted).
Returns:
Endpoint: Public endpoint URL
Raises:
HTTPException: If sandbox not found, endpoint not available, or signed
routes are not supported by the runtime/configuration (400).
"""
if use_server_proxy and expires is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
"use_server_proxy cannot be combined with expires; "
"signed endpoints require the gateway route."
),
},
)
# Delegate to the service layer for endpoint resolution
endpoint = sandbox_service.get_endpoint(sandbox_id, port, expires=expires)
if use_server_proxy:
# Prefer configured external address when available.
base_url = str(request.base_url).rstrip("/")
eip = (get_config().server.eip or "").strip().rstrip("/")
if eip:
base_url = eip
base_url = base_url.replace("https://", "").replace("http://", "")
endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}"
return endpoint