LetsBeBiz-Redesign/letsbe-orchestrator/app/services/local_bootstrap.py

168 lines
5.8 KiB
Python
Raw Normal View History

"""
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