#!/bin/sh # ============================================================================= # prestart.sh — Container entrypoint for CRM API container # # Responsibilities: # 1. Run Alembic DB migrations (alembic upgrade head) using the migration user. # 2. Set passwords for all application DB roles (crm_api, crm_auth, crm_worker, crm_migration). # 3. Add owner_id to plugin tables that were created after core migration 0054. # 4. Start uvicorn as PID 1 (so signals like SIGTERM are forwarded correctly). # # Notes: # - `set -e` ensures the container crashes loudly if migrations fail. # - The ARQ worker runs in a separate container (see worker.sh / docker-compose). # - Migrations use MIGRATION_DATABASE_URL (crm_user for bootstrap, then crm_migration). # - The app uses DATABASE_URL (crm_api, NOSUPERUSER, NOBYPASSRLS). # ============================================================================= set -e # Use MIGRATION_DATABASE_URL for alembic (falls back to DATABASE_URL for backwards compat) export ALEMBIC_DATABASE_URL="${MIGRATION_DATABASE_URL:-$DATABASE_URL}" # Configure alembic to use the migration database URL export ALEMBIC_DATABASE_URL echo "[prestart] $(date -u +%Y-%m-%dT%H:%M:%SZ) - Running alembic upgrade head (owner user)..." # Temporarily override DATABASE_URL for alembic DATABASE_URL="$ALEMBIC_DATABASE_URL" alembic upgrade head echo "[prestart] DB migrations completed successfully." # Set passwords for all application DB roles (crm_api, crm_auth, crm_worker, crm_migration) # Migration 0070 creates these roles without passwords; we set them here so the # API/Worker/Auth connections can authenticate. echo "[prestart] Setting DB role passwords..." cat > /tmp/set_role_passwords.py << 'PYEOF' import asyncio import os import re from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text async def set_passwords(): db_url = os.environ.get('MIGRATION_DATABASE_URL', os.environ.get('DATABASE_URL', '')) if not db_url: print('[prestart] WARNING: No DB URL for password setup') return match = re.search(r'://([^:]+):([^@]+)@', db_url) if not match: print('[prestart] WARNING: Could not extract password from DB URL') return pwd = match.group(2) engine = create_async_engine(db_url) roles = ['crm_api', 'crm_auth', 'crm_worker', 'crm_migration'] try: async with engine.begin() as conn: for role in roles: try: await conn.execute(text(f"ALTER ROLE {role} WITH LOGIN PASSWORD '{pwd}'")) print(f'[prestart] Password set for {role}') except Exception as e: print(f'[prestart] WARNING: Could not set password for {role}: {e}') print('[prestart] DB role passwords set.') except Exception as e: print(f'[prestart] WARNING: Could not set DB role passwords: {e}') finally: await engine.dispose() asyncio.run(set_passwords()) PYEOF python3 /tmp/set_role_passwords.py rm -f /tmp/set_role_passwords.py # Grant DELETE on all tables to crm_api, crm_auth, crm_worker (BUG-030 fix) echo "[prestart] Granting DELETE on all tables to crm_api, crm_auth, crm_worker..." python3 -c " import asyncio, os from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text async def grant_delete(): db_url = os.environ.get('MIGRATION_DATABASE_URL', os.environ.get('DATABASE_URL', '')) if not db_url: print('[prestart] No DATABASE_URL found, skipping GRANT DELETE') return engine = create_async_engine(db_url) try: async with engine.begin() as conn: for role in ['crm_api', 'crm_auth', 'crm_worker']: try: await conn.execute(text(f'GRANT DELETE ON ALL TABLES IN SCHEMA public TO {role}')) print(f'[prestart] GRANT DELETE to {role} OK') except Exception as e: print(f'[prestart] WARNING: Could not GRANT DELETE to {role}: {e}') print('[prestart] GRANT DELETE complete.') except Exception as e: print(f'[prestart] WARNING: Could not GRANT DELETE: {e}') finally: await engine.dispose() asyncio.run(grant_delete()) " # Set crm_runtime password if RUNTIME_DB_PASSWORD is set (legacy support) if [ -n "$RUNTIME_DB_PASSWORD" ]; then echo "[prestart] Setting crm_runtime password..." python3 -c " import asyncio import os from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy import text async def set_password(): db_url = os.environ.get('MIGRATION_DATABASE_URL', os.environ.get('DATABASE_URL', '')) if not db_url: print('[prestart] WARNING: No DB URL for password setup') return engine = create_async_engine(db_url) pwd = os.environ.get('RUNTIME_DB_PASSWORD', '') try: async with engine.begin() as conn: await conn.execute(text( "ALTER ROLE crm_runtime WITH LOGIN PASSWORD :pwd NOSUPERUSER NOBYPASSRLS" ), {"pwd": pwd}) print('[prestart] crm_runtime password set.') except Exception as e: print(f'[prestart] WARNING: Could not set crm_runtime password: {e}') finally: await engine.dispose() asyncio.run(set_password()) " fi echo "[prestart] Syncing plugin table schemas..." python3 /app/scripts/sync_plugin_schema.py echo "[prestart] Seeding admin user if not exists..." python3 /app/scripts/seed_admin.py || echo "[prestart] WARNING: Admin seed failed (may already exist)" echo "[prestart] Auto-installing and activating discovered plugins..." python3 -c " import asyncio from app.plugins.registry import get_registry from app.core.db import get_session_factory from sqlalchemy import select from app.models.plugin import Plugin as PluginModel async def auto_activate_plugins(): r = get_registry() discovered = r.discover_builtins() factory = get_session_factory() async with factory() as db: for name in discovered: result = await db.execute(select(PluginModel).where(PluginModel.name == name)) record = result.scalar_one_or_none() try: if record is None: await r.install(db, name) await r.activate(db, name) print(f'[prestart] Installed + activated plugin: {name}') elif not record.active: await r.activate(db, name) print(f'[prestart] Activated plugin: {name}') except Exception as e: print(f'[prestart] WARNING: Could not activate plugin {name}: {e}') await db.rollback() await db.commit() print('[prestart] Plugin auto-activation complete.') asyncio.run(auto_activate_plugins()) " || echo "[prestart] WARNING: Plugin auto-activation failed" echo "[prestart] Starting uvicorn on 0.0.0.0:8000 (workers=1)..." exec uvicorn app.main:app \ --host 0.0.0.0 \ --port 8000 \ --workers 1