2025-09-30 08:12:45 +02:00
|
|
|
"""
|
|
|
|
|
Database Initialization Manager
|
|
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
Handles Alembic-based migrations with autogenerate support:
|
|
|
|
|
1. First-time deployment: Generate initial migration from models
|
|
|
|
|
2. Subsequent deployments: Run pending migrations
|
|
|
|
|
3. Development reset: Drop tables and regenerate migrations
|
2025-09-30 08:12:45 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import asyncio
|
|
|
|
|
import structlog
|
|
|
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from sqlalchemy import text, inspect
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from alembic.config import Config
|
|
|
|
|
from alembic import command
|
|
|
|
|
from alembic.runtime.migration import MigrationContext
|
|
|
|
|
from alembic.script import ScriptDirectory
|
|
|
|
|
|
|
|
|
|
from .base import DatabaseManager, Base
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DatabaseInitManager:
|
|
|
|
|
"""
|
2025-09-30 21:58:10 +02:00
|
|
|
Manages database initialization using Alembic migrations exclusively.
|
2025-10-01 12:17:59 +02:00
|
|
|
|
|
|
|
|
Two modes:
|
|
|
|
|
1. Migration mode (for migration jobs): Runs alembic upgrade head
|
|
|
|
|
2. Verification mode (for services): Only verifies database is ready
|
2025-09-30 08:12:45 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
database_manager: DatabaseManager,
|
|
|
|
|
service_name: str,
|
|
|
|
|
alembic_ini_path: Optional[str] = None,
|
|
|
|
|
models_module: Optional[str] = None,
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only: bool = True, # Default: services only verify
|
|
|
|
|
force_recreate: bool = False
|
2025-09-30 08:12:45 +02:00
|
|
|
):
|
|
|
|
|
self.database_manager = database_manager
|
|
|
|
|
self.service_name = service_name
|
|
|
|
|
self.alembic_ini_path = alembic_ini_path
|
|
|
|
|
self.models_module = models_module
|
2025-10-01 12:17:59 +02:00
|
|
|
self.verify_only = verify_only
|
2025-09-30 08:12:45 +02:00
|
|
|
self.force_recreate = force_recreate
|
|
|
|
|
self.logger = logger.bind(service=service_name)
|
|
|
|
|
|
|
|
|
|
async def initialize_database(self) -> Dict[str, Any]:
|
|
|
|
|
"""
|
2025-10-01 12:17:59 +02:00
|
|
|
Main initialization method.
|
|
|
|
|
|
|
|
|
|
Two modes:
|
|
|
|
|
1. verify_only=True (default, for services):
|
|
|
|
|
- Verifies database is ready
|
|
|
|
|
- Checks tables exist
|
|
|
|
|
- Checks alembic_version exists
|
|
|
|
|
- DOES NOT run migrations
|
|
|
|
|
|
|
|
|
|
2. verify_only=False (for migration jobs only):
|
|
|
|
|
- Runs alembic upgrade head
|
|
|
|
|
- Applies pending migrations
|
|
|
|
|
- Can force recreate if needed
|
|
|
|
|
"""
|
|
|
|
|
if self.verify_only:
|
|
|
|
|
self.logger.info("Database verification mode - checking database is ready")
|
|
|
|
|
return await self._verify_database_ready()
|
|
|
|
|
else:
|
|
|
|
|
self.logger.info("Migration mode - running database migrations")
|
|
|
|
|
return await self._run_migrations_mode()
|
2025-09-30 21:58:10 +02:00
|
|
|
|
2025-10-01 12:17:59 +02:00
|
|
|
async def _verify_database_ready(self) -> Dict[str, Any]:
|
2025-09-30 08:12:45 +02:00
|
|
|
"""
|
2025-10-01 12:17:59 +02:00
|
|
|
Verify database is ready for service startup.
|
|
|
|
|
Services should NOT run migrations - only verify they've been applied.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Check alembic configuration exists
|
|
|
|
|
if not self.alembic_ini_path or not os.path.exists(self.alembic_ini_path):
|
|
|
|
|
raise Exception(f"Alembic configuration not found at {self.alembic_ini_path}")
|
|
|
|
|
|
|
|
|
|
# Check database state
|
|
|
|
|
db_state = await self._check_database_state()
|
|
|
|
|
self.logger.info("Database state checked", state=db_state)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-10-01 12:17:59 +02:00
|
|
|
# Verify migrations exist
|
|
|
|
|
if not db_state["has_migrations"]:
|
|
|
|
|
raise Exception(
|
|
|
|
|
f"No migration files found for {self.service_name}. "
|
|
|
|
|
f"Migrations must be generated and included in the Docker image."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Verify database is not empty
|
|
|
|
|
if db_state["is_empty"]:
|
|
|
|
|
raise Exception(
|
|
|
|
|
f"Database is empty. Migration job must run before service startup. "
|
|
|
|
|
f"Ensure migration job completes successfully before starting services."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Verify alembic_version table exists
|
|
|
|
|
if not db_state["has_alembic_version"]:
|
|
|
|
|
raise Exception(
|
|
|
|
|
f"No alembic_version table found. Migration job must run before service startup."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Verify current revision exists
|
|
|
|
|
if not db_state["current_revision"]:
|
|
|
|
|
raise Exception(
|
|
|
|
|
f"No current migration revision found. Database may not be properly initialized."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
"Database verification successful",
|
|
|
|
|
migration_count=db_state["migration_count"],
|
|
|
|
|
current_revision=db_state["current_revision"],
|
|
|
|
|
table_count=len(db_state["existing_tables"])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"action": "verified",
|
|
|
|
|
"message": "Database verified successfully - ready for service",
|
|
|
|
|
"current_revision": db_state["current_revision"],
|
|
|
|
|
"migration_count": db_state["migration_count"],
|
|
|
|
|
"table_count": len(db_state["existing_tables"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error("Database verification failed", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def _run_migrations_mode(self) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Run migrations mode - for migration jobs only.
|
|
|
|
|
"""
|
2025-09-30 08:12:45 +02:00
|
|
|
try:
|
2025-09-30 21:58:10 +02:00
|
|
|
if not self.alembic_ini_path or not os.path.exists(self.alembic_ini_path):
|
|
|
|
|
raise Exception(f"Alembic configuration not found at {self.alembic_ini_path}")
|
|
|
|
|
|
2025-09-30 08:12:45 +02:00
|
|
|
# Check current database state
|
|
|
|
|
db_state = await self._check_database_state()
|
|
|
|
|
self.logger.info("Database state checked", state=db_state)
|
|
|
|
|
|
2025-10-01 12:17:59 +02:00
|
|
|
# Handle force recreate
|
2025-09-30 08:12:45 +02:00
|
|
|
if self.force_recreate:
|
2025-10-01 12:17:59 +02:00
|
|
|
return await self._handle_force_recreate()
|
|
|
|
|
|
|
|
|
|
# Check migrations exist
|
|
|
|
|
if not db_state["has_migrations"]:
|
|
|
|
|
raise Exception(
|
|
|
|
|
f"No migration files found for {self.service_name}. "
|
|
|
|
|
f"Generate migrations using regenerate_migrations_k8s.sh script."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Run migrations
|
|
|
|
|
result = await self._handle_run_migrations()
|
|
|
|
|
|
|
|
|
|
self.logger.info("Migration mode completed", result=result)
|
2025-09-30 08:12:45 +02:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-10-01 12:17:59 +02:00
|
|
|
self.logger.error("Migration mode failed", error=str(e))
|
2025-09-30 08:12:45 +02:00
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def _check_database_state(self) -> Dict[str, Any]:
|
2025-09-30 21:58:10 +02:00
|
|
|
"""Check the current state of migrations"""
|
2025-09-30 08:12:45 +02:00
|
|
|
state = {
|
2025-09-30 21:58:10 +02:00
|
|
|
"has_migrations": False,
|
|
|
|
|
"migration_count": 0,
|
2025-09-30 08:12:45 +02:00
|
|
|
"is_empty": False,
|
|
|
|
|
"existing_tables": [],
|
2025-09-30 21:58:10 +02:00
|
|
|
"has_alembic_version": False,
|
|
|
|
|
"current_revision": None
|
2025-09-30 08:12:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
2025-09-30 21:58:10 +02:00
|
|
|
# Check if migration files exist
|
|
|
|
|
migrations_dir = self._get_migrations_versions_dir()
|
|
|
|
|
if migrations_dir.exists():
|
|
|
|
|
migration_files = list(migrations_dir.glob("*.py"))
|
|
|
|
|
migration_files = [f for f in migration_files if f.name != "__pycache__" and not f.name.startswith("_")]
|
|
|
|
|
state["migration_count"] = len(migration_files)
|
|
|
|
|
state["has_migrations"] = len(migration_files) > 0
|
|
|
|
|
self.logger.info("Found migration files", count=len(migration_files))
|
|
|
|
|
|
|
|
|
|
# Check database tables
|
2025-09-30 08:12:45 +02:00
|
|
|
async with self.database_manager.get_session() as session:
|
2025-09-30 21:58:10 +02:00
|
|
|
existing_tables = await self._get_existing_tables(session)
|
2025-09-30 08:12:45 +02:00
|
|
|
state["existing_tables"] = existing_tables
|
|
|
|
|
state["is_empty"] = len(existing_tables) == 0
|
|
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Check alembic_version table
|
2025-09-30 08:12:45 +02:00
|
|
|
if "alembic_version" in existing_tables:
|
|
|
|
|
state["has_alembic_version"] = True
|
|
|
|
|
result = await session.execute(text("SELECT version_num FROM alembic_version"))
|
|
|
|
|
version = result.scalar()
|
2025-09-30 21:58:10 +02:00
|
|
|
state["current_revision"] = version
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.warning("Error checking database state", error=str(e))
|
|
|
|
|
|
|
|
|
|
return state
|
|
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
async def _handle_run_migrations(self) -> Dict[str, Any]:
|
|
|
|
|
"""Handle normal migration scenario - run pending migrations"""
|
|
|
|
|
self.logger.info("Running pending migrations")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self._run_migrations()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"action": "migrations_applied",
|
|
|
|
|
"message": "Pending migrations applied successfully"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error("Failed to run migrations", error=str(e))
|
|
|
|
|
raise
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
async def _handle_force_recreate(self) -> Dict[str, Any]:
|
2025-09-30 21:58:10 +02:00
|
|
|
"""Handle development reset scenario - drop and recreate tables using existing migrations"""
|
|
|
|
|
self.logger.info("Force recreate: dropping tables and rerunning migrations")
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
try:
|
|
|
|
|
# Drop all tables
|
|
|
|
|
await self._drop_all_tables()
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Apply migrations from scratch
|
|
|
|
|
await self._run_migrations()
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
return {
|
|
|
|
|
"action": "force_recreate",
|
|
|
|
|
"tables_dropped": True,
|
|
|
|
|
"migrations_applied": True,
|
|
|
|
|
"message": "Database recreated from existing migrations"
|
|
|
|
|
}
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error("Failed to force recreate", error=str(e))
|
|
|
|
|
raise
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
async def _run_migrations(self):
|
|
|
|
|
"""Run pending Alembic migrations (upgrade head)"""
|
|
|
|
|
try:
|
|
|
|
|
def run_alembic_upgrade():
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Ensure we're in the correct working directory
|
|
|
|
|
alembic_dir = Path(self.alembic_ini_path).parent
|
|
|
|
|
original_cwd = os.getcwd()
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
try:
|
|
|
|
|
os.chdir(alembic_dir)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
alembic_cfg = Config(self.alembic_ini_path)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Set the SQLAlchemy URL from the database manager
|
|
|
|
|
alembic_cfg.set_main_option("sqlalchemy.url", str(self.database_manager.database_url))
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Run upgrade
|
|
|
|
|
command.upgrade(alembic_cfg, "head")
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
finally:
|
|
|
|
|
os.chdir(original_cwd)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
# Run in executor to avoid blocking
|
|
|
|
|
await asyncio.get_event_loop().run_in_executor(None, run_alembic_upgrade)
|
|
|
|
|
self.logger.info("Migrations applied successfully")
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-09-30 21:58:10 +02:00
|
|
|
self.logger.error("Failed to run migrations", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
async def _drop_all_tables(self):
|
|
|
|
|
"""Drop all tables (for development reset)"""
|
|
|
|
|
try:
|
|
|
|
|
async with self.database_manager.async_engine.begin() as conn:
|
|
|
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
|
self.logger.info("All tables dropped")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error("Failed to drop tables", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
def _get_migrations_versions_dir(self) -> Path:
|
|
|
|
|
"""Get the migrations/versions directory path"""
|
|
|
|
|
alembic_path = Path(self.alembic_ini_path).parent
|
|
|
|
|
return alembic_path / "migrations" / "versions"
|
2025-09-30 08:12:45 +02:00
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
async def _get_existing_tables(self, session: AsyncSession) -> List[str]:
|
2025-09-30 08:12:45 +02:00
|
|
|
"""Get list of existing tables in the database"""
|
|
|
|
|
def get_tables_sync(connection):
|
|
|
|
|
insp = inspect(connection)
|
|
|
|
|
return insp.get_table_names()
|
|
|
|
|
|
2025-09-30 21:58:10 +02:00
|
|
|
connection = await session.connection()
|
|
|
|
|
return await connection.run_sync(get_tables_sync)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_init_manager(
|
|
|
|
|
database_manager: DatabaseManager,
|
|
|
|
|
service_name: str,
|
|
|
|
|
service_path: Optional[str] = None,
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only: bool = True,
|
|
|
|
|
force_recreate: bool = False
|
2025-09-30 08:12:45 +02:00
|
|
|
) -> DatabaseInitManager:
|
|
|
|
|
"""
|
|
|
|
|
Factory function to create a DatabaseInitManager with auto-detected paths
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
database_manager: DatabaseManager instance
|
|
|
|
|
service_name: Name of the service
|
|
|
|
|
service_path: Path to service directory (auto-detected if None)
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only: True = verify DB ready (services), False = run migrations (jobs only)
|
|
|
|
|
force_recreate: Force recreate tables (requires verify_only=False)
|
2025-09-30 08:12:45 +02:00
|
|
|
"""
|
|
|
|
|
# Auto-detect paths if not provided
|
|
|
|
|
if service_path is None:
|
|
|
|
|
# Try Docker container path first (service files at root level)
|
|
|
|
|
if os.path.exists("alembic.ini"):
|
|
|
|
|
service_path = "."
|
|
|
|
|
else:
|
|
|
|
|
# Fallback to development path
|
|
|
|
|
service_path = f"services/{service_name}"
|
|
|
|
|
|
|
|
|
|
# Set up paths based on environment
|
|
|
|
|
if service_path == ".":
|
|
|
|
|
# Docker container environment
|
|
|
|
|
alembic_ini_path = "alembic.ini"
|
|
|
|
|
models_module = "app.models"
|
|
|
|
|
else:
|
|
|
|
|
# Development environment
|
|
|
|
|
alembic_ini_path = f"{service_path}/alembic.ini"
|
|
|
|
|
models_module = f"services.{service_name}.app.models"
|
|
|
|
|
|
|
|
|
|
# Check if paths exist
|
|
|
|
|
if not os.path.exists(alembic_ini_path):
|
|
|
|
|
logger.warning("Alembic config not found", path=alembic_ini_path)
|
|
|
|
|
alembic_ini_path = None
|
|
|
|
|
|
|
|
|
|
return DatabaseInitManager(
|
|
|
|
|
database_manager=database_manager,
|
|
|
|
|
service_name=service_name,
|
|
|
|
|
alembic_ini_path=alembic_ini_path,
|
|
|
|
|
models_module=models_module,
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only=verify_only,
|
|
|
|
|
force_recreate=force_recreate
|
2025-09-30 08:12:45 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def initialize_service_database(
|
|
|
|
|
database_manager: DatabaseManager,
|
|
|
|
|
service_name: str,
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only: bool = True,
|
|
|
|
|
force_recreate: bool = False
|
2025-09-30 08:12:45 +02:00
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""
|
2025-10-01 12:17:59 +02:00
|
|
|
Convenience function for database initialization
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
database_manager: DatabaseManager instance
|
|
|
|
|
service_name: Name of the service
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only: True = verify DB ready (default, services), False = run migrations (jobs only)
|
|
|
|
|
force_recreate: Force recreate tables (requires verify_only=False)
|
2025-09-30 08:12:45 +02:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with initialization results
|
|
|
|
|
"""
|
|
|
|
|
init_manager = create_init_manager(
|
|
|
|
|
database_manager=database_manager,
|
|
|
|
|
service_name=service_name,
|
2025-10-01 12:17:59 +02:00
|
|
|
verify_only=verify_only,
|
|
|
|
|
force_recreate=force_recreate
|
2025-09-30 08:12:45 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await init_manager.initialize_database()
|