from __future__ import annotations import asyncio import logging from collections.abc import Callable from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import timedelta from pathlib import Path from typing import Any from fastapi import FastAPI, status from fastapi import Request from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.types import Scope from cloud.auth import ( ChainedAuthProvider, ConfiguredEnrollmentTokenProvider, RepositoryHostAuthProvider, UserSessionAuthProvider, create_auth_provider, ) from cloud.config import CloudConfig from cloud.control_config import ( CloudConfigurationError, CloudControlConfig, load_control_config, validate_control_config, ) from cloud.database import CloudDatabase from cloud.internal_api.api import create_internal_router from cloud.plugins import PluginRegistry from cloud.observability import ( CORRELATION_HEADER, bind_correlation_id, new_correlation_id, normalize_correlation_id, reset_correlation_id, ) from cloud.pool import DevicePool from cloud.scheduler import TaskScheduler from cloud.schema import require_current_schema from cloud.sdk.api import create_cloud_router from cloud.sdk.user_api import create_user_auth_router from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings from core.models import utc_now DatabaseFactory = Callable[[CloudControlConfig], CloudDatabase] logger = logging.getLogger(__name__) class _RepositoryProxy: def __init__(self) -> None: self._target: Any | None = None def bind(self, target: Any) -> None: self._target = target def unbind(self) -> None: self._target = None def __getattr__(self, name: str) -> Any: if self._target is None: raise RuntimeError("cloud repository is not available outside app lifespan") return getattr(self._target, name) @dataclass(frozen=True) class CloudApplicationServices: repository: _RepositoryProxy pool: DevicePool scheduler: TaskScheduler plugin_registry: PluginRegistry auth_provider: Any user_auth_service: UserAuthService class SpaStaticFiles(StaticFiles): """``StaticFiles`` variant that falls back to ``index.html`` for SPA routes. ``StaticFiles(html=True)`` only serves ``index.html`` for the mount root and for directory roots; an unknown path like ``/console/tasks/abc`` raises a plain 404, which breaks deep-link refreshes in a browser-running SPA. This subclass intercepts 404s for non-asset paths and re-serves ``index.html`` so the SPA router can take over. """ async def get_response(self, path: str, scope: Scope) -> Any: try: return await super().get_response(path, scope) except StarletteHTTPException as exc: if exc.status_code == 404 and path != "index.html": return await super().get_response("index.html", scope) raise def create_app( *, config: CloudControlConfig | None = None, database_factory: DatabaseFactory | None = None, ) -> FastAPI: """Create the independently deployable cloud API application.""" control_config = config or load_control_config() validate_control_config(control_config) build_database = database_factory or _default_database_factory configured_auth_provider = create_auth_provider( control_config.credentials, allow_insecure_anonymous=control_config.allow_insecure_anonymous, ) repository = _RepositoryProxy() user_auth_service = UserAuthService( repository, settings=UserAuthSettings( session_idle_ttl=timedelta(seconds=control_config.user_session_idle_seconds), session_absolute_ttl=timedelta( seconds=control_config.user_session_absolute_seconds ), login_failure_limit=control_config.login_failure_limit, login_failure_window=timedelta( seconds=control_config.login_failure_window_seconds ), login_block_duration=timedelta(seconds=control_config.login_block_seconds), cookie_secure=control_config.session_cookie_secure, ), ) auth_provider = ChainedAuthProvider( ( configured_auth_provider, UserSessionAuthProvider(user_auth_service), RepositoryHostAuthProvider(repository), # type: ignore[arg-type] ) ) enrollment_auth_provider = ConfiguredEnrollmentTokenProvider( control_config.enrollment_credentials ) domain_config = CloudConfig( lease_duration_seconds=control_config.lease_duration_seconds, ) pool = DevicePool(repository, domain_config) # type: ignore[arg-type] scheduler = TaskScheduler(pool, repository, domain_config) # type: ignore[arg-type] plugin_registry = PluginRegistry(repository) # type: ignore[arg-type] services = CloudApplicationServices( repository=repository, pool=pool, scheduler=scheduler, plugin_registry=plugin_registry, auth_provider=auth_provider, user_auth_service=user_auth_service, ) @asynccontextmanager async def lifespan(app: FastAPI): database = build_database(control_config) repository.bind(database.repository) app.state.cloud_config = control_config app.state.database = database app.state.cloud_services = services app.state.startup_complete = False app.state.schema_ready = False stop_workers = asyncio.Event() worker_tasks: list[asyncio.Task[None]] = [] try: repository.health_check() if control_config.environment == "production": require_current_schema(control_config.database_url) app.state.schema_ready = True worker_tasks = [ asyncio.create_task( _run_scheduler_loop( services, stop_workers, interval_seconds=control_config.scheduler_interval_seconds, ), name="cloud-scheduler", ), asyncio.create_task( _run_lease_reaper_loop( services, stop_workers, interval_seconds=(control_config.lease_reaper_interval_seconds), max_attempts=control_config.max_task_attempts, ), name="cloud-lease-reaper", ), ] app.state.worker_tasks = tuple(worker_tasks) app.state.startup_complete = True yield finally: app.state.startup_complete = False stop_workers.set() try: if worker_tasks: await asyncio.gather(*worker_tasks) finally: try: database.close() finally: repository.unbind() app = FastAPI(title="Device Cloud API", lifespan=lifespan) if control_config.cors_allowed_origins: from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=list(control_config.cors_allowed_origins), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def correlation_logging(request: Request, call_next): correlation_id = normalize_correlation_id( request.headers.get(CORRELATION_HEADER) ) correlation_token = bind_correlation_id(correlation_id) try: response = await call_next(request) if ( request.cookies.get(USER_SESSION_COOKIE) and not request.headers.get("authorization") and response.status_code == status.HTTP_401_UNAUTHORIZED ): _clear_user_auth_cookies(response, control_config) logger.info( "cloud request completed", extra={ "correlation_id": correlation_id, "method": request.method, "path": request.url.path, "status_code": response.status_code, }, ) response.headers[CORRELATION_HEADER] = correlation_id return response finally: reset_correlation_id(correlation_token) @app.get("/health/live") def health_live() -> dict[str, str]: return {"status": "live"} @app.get("/health/ready") def health_ready(): checks = { "configuration": bool(getattr(app.state, "startup_complete", False)), "database": False, "schema": bool(getattr(app.state, "schema_ready", False)), "workers": False, } if checks["configuration"]: try: services.repository.health_check() checks["database"] = True except Exception: checks["database"] = False worker_state = getattr(app.state, "worker_tasks", ()) checks["workers"] = bool(worker_state) and all( not task.done() for task in worker_state ) ready = all(checks.values()) return JSONResponse( status_code=( status.HTTP_200_OK if ready else status.HTTP_503_SERVICE_UNAVAILABLE ), content={"status": "ready" if ready else "not_ready", "checks": checks}, ) app.include_router( create_cloud_router( pool=pool, scheduler=scheduler, plugin_registry=plugin_registry, auth_provider=auth_provider, csrf_validator=lambda request, principal: _valid_csrf_request( request, principal, user_auth_service, ), ) ) app.include_router( create_user_auth_router( user_auth_service=user_auth_service, auth_provider=auth_provider, config=control_config, ) ) app.include_router( create_internal_router( pool=pool, auth_provider=auth_provider, enrollment_auth_provider=enrollment_auth_provider, lease_duration_seconds=control_config.lease_duration_seconds, ) ) if control_config.console_static_dir: dist_dir = Path(control_config.console_static_dir) if not dist_dir.is_dir(): raise CloudConfigurationError( f"CLOUD_CONSOLE_STATIC_DIR is not a directory: {dist_dir}" ) @app.get("/", include_in_schema=False) async def _redirect_to_console() -> RedirectResponse: return RedirectResponse(url="/console/") app.mount( "/console", SpaStaticFiles(directory=str(dist_dir), html=True), name="cloud-console", ) return app def _default_database_factory(config: CloudControlConfig) -> CloudDatabase: return CloudDatabase( config.database_url, create_schema=config.environment != "production", ) def _valid_csrf_request( request: Request, principal: Any, user_auth_service: UserAuthService, ) -> bool: if principal.session_id is None: return True return user_auth_service.validate_csrf( session_token=request.cookies.get(USER_SESSION_COOKIE), csrf_cookie=request.cookies.get(USER_CSRF_COOKIE), csrf_header=request.headers.get("x-csrf-token"), ) def _clear_user_auth_cookies(response: Any, config: CloudControlConfig) -> None: response.delete_cookie( USER_SESSION_COOKIE, path="/", secure=config.session_cookie_secure, httponly=True, samesite="lax", ) response.delete_cookie( USER_CSRF_COOKIE, path="/", secure=config.session_cookie_secure, httponly=False, samesite="lax", ) async def _run_scheduler_loop( services: CloudApplicationServices, stop: asyncio.Event, *, interval_seconds: float, ) -> None: while not stop.is_set(): correlation_token = bind_correlation_id(new_correlation_id()) try: services.scheduler.assign() except Exception: logger.exception( "cloud lifecycle iteration failed", extra={"worker": "scheduler"}, ) finally: reset_correlation_id(correlation_token) if await _wait_for_stop(stop, interval_seconds): return async def _run_lease_reaper_loop( services: CloudApplicationServices, stop: asyncio.Event, *, interval_seconds: float, max_attempts: int, ) -> None: while not stop.is_set(): correlation_token = bind_correlation_id(new_correlation_id()) try: services.repository.reap_expired_leases( now=utc_now(), max_attempts=max_attempts, ) except Exception: logger.exception( "cloud lifecycle iteration failed", extra={"worker": "lease_reaper"}, ) finally: reset_correlation_id(correlation_token) if await _wait_for_stop(stop, interval_seconds): return async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool: try: await asyncio.wait_for(stop.wait(), timeout=interval_seconds) except TimeoutError: return False return True