168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
|
|
"""
|
||
|
|
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
|