""" Local bootstrap service for single-tenant mode. Handles automatic tenant creation when LOCAL_MODE is enabled. Designed to be migration-safe: gracefully handles cases where database tables don't exist yet. """ import asyncio import logging from typing import Optional from uuid import UUID from sqlalchemy import select, text from sqlalchemy.exc import OperationalError, ProgrammingError from app.config import settings from app.db import async_session_maker from app.models import Tenant logger = logging.getLogger(__name__) class LocalBootstrapService: """ Service for bootstrapping local single-tenant mode. When LOCAL_MODE=true: - Waits for database migrations to complete - Creates or retrieves the local tenant - Makes tenant_id available for the meta endpoint When LOCAL_MODE=false: - Does nothing (multi-tenant mode unchanged) """ # Class-level state for meta endpoint access _local_tenant_id: Optional[UUID] = None _bootstrap_attempted: bool = False _bootstrap_error: Optional[str] = None # Bootstrap configuration MAX_RETRIES = 30 # Max attempts waiting for migrations RETRY_DELAY_SECONDS = 2 # Delay between retries LOCAL_TENANT_NAME = "local" @classmethod def get_local_tenant_id(cls) -> Optional[UUID]: """Get the local tenant ID if bootstrap succeeded.""" return cls._local_tenant_id @classmethod def get_bootstrap_status(cls) -> dict: """Get bootstrap status for diagnostics.""" return { "attempted": cls._bootstrap_attempted, "success": cls._local_tenant_id is not None, "tenant_id": str(cls._local_tenant_id) if cls._local_tenant_id else None, "error": cls._bootstrap_error, } @classmethod async def run(cls) -> None: """ Run the bootstrap process. Only executes if LOCAL_MODE is enabled. Safe to call multiple times (idempotent). """ if not settings.LOCAL_MODE: logger.debug("LOCAL_MODE is disabled, skipping bootstrap") return if cls._bootstrap_attempted: logger.debug("Bootstrap already attempted, skipping") return cls._bootstrap_attempted = True logger.info("LOCAL_MODE enabled, starting local tenant bootstrap") # Validate required settings if not settings.INSTANCE_ID: cls._bootstrap_error = "INSTANCE_ID is required when LOCAL_MODE is enabled" logger.error(cls._bootstrap_error) return try: await cls._bootstrap_with_retry() except Exception as e: cls._bootstrap_error = str(e) logger.exception("Bootstrap failed with unexpected error") @classmethod async def _bootstrap_with_retry(cls) -> None: """ Attempt bootstrap with retry logic for migration safety. Waits for the tenants table to exist before proceeding. """ for attempt in range(1, cls.MAX_RETRIES + 1): try: await cls._ensure_local_tenant() logger.info(f"Local tenant bootstrap succeeded (tenant_id={cls._local_tenant_id})") return except (OperationalError, ProgrammingError) as e: # These errors typically indicate migrations haven't run yet error_msg = str(e).lower() if "does not exist" in error_msg or "no such table" in error_msg: if attempt < cls.MAX_RETRIES: logger.warning( f"Database table not ready (attempt {attempt}/{cls.MAX_RETRIES}), " f"retrying in {cls.RETRY_DELAY_SECONDS}s..." ) await asyncio.sleep(cls.RETRY_DELAY_SECONDS) continue else: cls._bootstrap_error = f"Migrations did not complete after {cls.MAX_RETRIES} attempts" logger.error(cls._bootstrap_error) return else: # Unexpected database error raise @classmethod async def _ensure_local_tenant(cls) -> None: """ Ensure the local tenant exists. Creates it if missing, retrieves it if already exists. """ async with async_session_maker() as session: # First, verify we can query the tenants table # This will fail fast if migrations haven't run await session.execute(text("SELECT 1 FROM tenants LIMIT 1")) # Check if local tenant exists result = await session.execute( select(Tenant).where(Tenant.name == cls.LOCAL_TENANT_NAME) ) tenant = result.scalar_one_or_none() if tenant: logger.info(f"Local tenant already exists (id={tenant.id})") cls._local_tenant_id = tenant.id return # Create local tenant tenant = Tenant( name=cls.LOCAL_TENANT_NAME, domain=settings.LOCAL_TENANT_DOMAIN, ) session.add(tenant) await session.commit() await session.refresh(tenant) cls._local_tenant_id = tenant.id logger.info(f"Created local tenant (id={tenant.id}, domain={settings.LOCAL_TENANT_DOMAIN})") @classmethod async def _check_table_exists(cls, table_name: str) -> bool: """Check if a database table exists.""" async with async_session_maker() as session: try: await session.execute(text(f"SELECT 1 FROM {table_name} LIMIT 1")) return True except (OperationalError, ProgrammingError): return False