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      )