main.py
1 # Copyright 2025 Alibaba Group Holding Ltd. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 15 """ 16 FastAPI application entry point for OpenSandbox Lifecycle API. 17 18 This module initializes the FastAPI application with middleware, routes, 19 and configuration for the sandbox lifecycle management service. 20 """ 21 22 import logging 23 import os 24 from contextlib import asynccontextmanager 25 from typing import Any 26 27 import httpx 28 from fastapi import FastAPI, Request 29 from fastapi.exceptions import HTTPException 30 from fastapi.middleware.cors import CORSMiddleware 31 from fastapi.responses import JSONResponse 32 33 from opensandbox_server.config import load_config 34 from opensandbox_server.integrations.renew_intent import start_renew_intent_consumer 35 from opensandbox_server.logging_config import configure_logging 36 from opensandbox_server.startup_guard import api_key_confirm 37 38 # Load configuration before initializing routers/middleware 39 app_config = load_config() 40 _log_config = configure_logging(app_config.log) 41 42 from opensandbox_server.api.devops import router as devops_router # noqa: E402 43 from opensandbox_server.api.pool import router as pool_router # noqa: E402 44 from opensandbox_server.api.lifecycle import router, sandbox_service # noqa: E402 45 from opensandbox_server.api.proxy import router as proxy_router # noqa: E402 46 from opensandbox_server.integrations.renew_intent.proxy_renew import ProxyRenewCoordinator # noqa: E402 47 from opensandbox_server.middleware.auth import AuthMiddleware # noqa: E402 48 from opensandbox_server.middleware.request_id import RequestIdMiddleware # noqa: E402 49 from opensandbox_server.services.extension_service import require_extension_service # noqa: E402 50 from opensandbox_server.services.runtime_resolver import ( # noqa: E402 51 validate_secure_runtime_on_startup, 52 ) 53 54 logger = logging.getLogger(__name__) 55 56 @asynccontextmanager 57 async def lifespan(app: FastAPI): 58 try: 59 api_key_confirm(configured_api_key=app_config.server.api_key) 60 except Exception as exc: 61 logger.error("API key startup confirmation failed: %s", exc) 62 os._exit(1) 63 64 app.state.http_client = httpx.AsyncClient(timeout=180.0) 65 66 # Validate secure runtime configuration at startup 67 try: 68 # Determine which runtime client to create based on config 69 docker_client = None 70 k8s_client = None 71 runtime_type = app_config.runtime.type 72 73 if runtime_type == "docker": 74 import docker 75 76 docker_client = docker.from_env() 77 logger.info("Validating secure runtime for Docker backend") 78 elif runtime_type == "kubernetes": 79 from opensandbox_server.services.k8s.client import K8sClient 80 81 k8s_client = K8sClient(app_config.kubernetes) 82 logger.info("Validating secure runtime for Kubernetes backend") 83 84 await validate_secure_runtime_on_startup( 85 app_config, 86 docker_client=docker_client, 87 k8s_client=k8s_client, 88 ) 89 90 except Exception as exc: 91 logger.error("Secure runtime validation failed: %s", exc) 92 raise 93 94 ext = require_extension_service(sandbox_service) 95 app.state.renew_intent_consumer = await start_renew_intent_consumer( 96 app_config, 97 sandbox_service, 98 ext, 99 ) 100 app.state.renew_intent_runner = app.state.renew_intent_consumer 101 102 app.state.proxy_renew_coordinator = ProxyRenewCoordinator( 103 app_config, 104 app.state.renew_intent_consumer, 105 ) 106 107 yield 108 109 consumer = getattr(app.state, "renew_intent_consumer", None) 110 if consumer is not None: 111 await consumer.stop() 112 await app.state.http_client.aclose() 113 114 115 # Initialize FastAPI application 116 app = FastAPI( 117 title="OpenSandbox Lifecycle API", 118 version="0.1.0", 119 description="The Sandbox Lifecycle API coordinates how untrusted workloads are created, " 120 "executed, paused, resumed, and finally disposed.", 121 docs_url="/docs", 122 redoc_url="/redoc", 123 lifespan=lifespan, 124 ) 125 126 # Attach global config for runtime access 127 app.state.config = app_config 128 129 # Middleware run in reverse order of addition: last added = first to run (outermost). 130 # Add auth and CORS first so they run after RequestIdMiddleware. 131 app.add_middleware(AuthMiddleware, config=app_config) 132 app.add_middleware( 133 CORSMiddleware, 134 allow_origins=["*"], 135 allow_credentials=True, 136 allow_methods=["*"], 137 allow_headers=["*"], 138 ) 139 # RequestIdMiddleware last = outermost: runs first, so every response (including 140 # 401 from AuthMiddleware) gets X-Request-ID and logs have request_id in context. 141 app.add_middleware(RequestIdMiddleware) 142 143 # Include API routes at root and versioned prefix. 144 # IMPORTANT: devops_router and pool_router MUST be registered before proxy_router 145 # because proxy_router contains catch-all routes that would swallow diagnostics paths. 146 app.include_router(router) 147 app.include_router(devops_router) 148 app.include_router(pool_router) 149 app.include_router(proxy_router) 150 app.include_router(router, prefix="/v1") 151 app.include_router(devops_router, prefix="/v1") 152 app.include_router(pool_router, prefix="/v1") 153 app.include_router(proxy_router, prefix="/v1") 154 155 DEFAULT_ERROR_CODE = "GENERAL::UNKNOWN_ERROR" 156 DEFAULT_ERROR_MESSAGE = "An unexpected error occurred." 157 158 159 def _normalize_error_detail(detail: Any) -> dict[str, str]: 160 """ 161 Ensure HTTP errors always conform to {"code": "...", "message": "..."}. 162 """ 163 if isinstance(detail, dict): 164 code = detail.get("code") or DEFAULT_ERROR_CODE 165 message = detail.get("message") or DEFAULT_ERROR_MESSAGE 166 return {"code": code, "message": message} 167 message = str(detail) if detail else DEFAULT_ERROR_MESSAGE 168 return {"code": DEFAULT_ERROR_CODE, "message": message} 169 170 171 @app.exception_handler(HTTPException) 172 async def sandbox_http_exception_handler(request: Request, exc: HTTPException): 173 """ 174 Flatten FastAPI HTTPException payload to the standard error schema. 175 """ 176 content = _normalize_error_detail(exc.detail) 177 return JSONResponse( 178 status_code=exc.status_code, 179 content=content, 180 headers=exc.headers, 181 ) 182 183 184 @app.get("/health") 185 async def health_check(): 186 """ 187 Health check endpoint. 188 189 Returns: 190 dict: Health status 191 """ 192 return {"status": "healthy"} 193 194 195 if __name__ == "__main__": 196 import uvicorn 197 198 # Run the application 199 uvicorn.run( 200 "opensandbox_server.main:app", 201 host=app_config.server.host, 202 port=app_config.server.port, 203 reload=True, 204 log_config=_log_config, 205 timeout_keep_alive=app_config.server.timeout_keep_alive, 206 )