mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 11:09:55 -06:00
395 lines
14 KiB
Python
395 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Enhanced Startup Script with Automatic Migration Detection
|
|
This script automatically detects database state and chooses the correct migration strategy
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import subprocess
|
|
import sqlite3
|
|
import psycopg2
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import logging
|
|
|
|
# Configure logging
|
|
try:
|
|
# Ensure logs directory exists
|
|
os.makedirs('/app/logs', exist_ok=True)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.StreamHandler(sys.stdout),
|
|
logging.FileHandler('/app/logs/timetracker_startup.log')
|
|
]
|
|
)
|
|
except Exception as e:
|
|
# Fallback to console-only logging if file logging fails
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[logging.StreamHandler(sys.stdout)]
|
|
)
|
|
print(f"Warning: Could not set up file logging: {e}")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def wait_for_database(db_url, max_retries=60, retry_delay=3):
|
|
"""Wait for database to be available"""
|
|
logger.info(f"Waiting for database to be available: {db_url}")
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
if db_url.startswith('postgresql'):
|
|
# Handle both postgresql:// and postgresql+psycopg2:// URLs
|
|
clean_url = db_url.replace('+psycopg2://', '://')
|
|
conn = psycopg2.connect(clean_url)
|
|
conn.close()
|
|
logger.info("✓ PostgreSQL database is available")
|
|
return True
|
|
elif db_url.startswith('sqlite'):
|
|
# For SQLite, just check if the file exists or can be created
|
|
db_file = db_url.replace('sqlite:///', '')
|
|
if os.path.exists(db_file) or os.access(os.path.dirname(db_file), os.W_OK):
|
|
logger.info("✓ SQLite database is available")
|
|
return True
|
|
except Exception as e:
|
|
logger.info(f"Database not ready (attempt {attempt + 1}/{max_retries}): {e}")
|
|
if attempt < max_retries - 1:
|
|
time.sleep(retry_delay)
|
|
|
|
logger.error("Database is not available after maximum retries")
|
|
return False
|
|
|
|
def detect_database_state(db_url):
|
|
"""Detect the current state of the database"""
|
|
logger.info("Analyzing database state...")
|
|
|
|
try:
|
|
if db_url.startswith('postgresql'):
|
|
# Handle both postgresql:// and postgresql+psycopg2:// URLs
|
|
clean_url = db_url.replace('+psycopg2://', '://')
|
|
conn = psycopg2.connect(clean_url)
|
|
cursor = conn.cursor()
|
|
|
|
# Check if alembic_version table exists
|
|
cursor.execute("""
|
|
SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_name = 'alembic_version'
|
|
)
|
|
""")
|
|
has_alembic = cursor.fetchone()[0]
|
|
|
|
# Get list of existing tables
|
|
cursor.execute("""
|
|
SELECT table_name
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
ORDER BY table_name
|
|
""")
|
|
existing_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Check if this is a fresh database
|
|
is_fresh = len(existing_tables) == 0
|
|
|
|
conn.close()
|
|
|
|
logger.info(f"Database state: has_alembic={has_alembic}, tables={existing_tables}, is_fresh={is_fresh}")
|
|
|
|
if has_alembic:
|
|
return 'migrated', existing_tables
|
|
elif existing_tables:
|
|
return 'legacy', existing_tables
|
|
else:
|
|
return 'fresh', []
|
|
|
|
elif db_url.startswith('sqlite'):
|
|
db_file = db_url.replace('sqlite:///', '')
|
|
|
|
if not os.path.exists(db_file):
|
|
return 'fresh', []
|
|
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
|
|
# Check if alembic_version table exists
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
|
has_alembic = cursor.fetchone() is not None
|
|
|
|
# Get list of existing tables
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
existing_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
conn.close()
|
|
|
|
logger.info(f"Database state: has_alembic={has_alembic}, tables={existing_tables}")
|
|
|
|
if has_alembic:
|
|
return 'migrated', existing_tables
|
|
elif existing_tables:
|
|
return 'legacy', existing_tables
|
|
else:
|
|
return 'fresh', []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error detecting database state: {e}")
|
|
return 'unknown', []
|
|
|
|
return 'unknown', []
|
|
|
|
def choose_migration_strategy(db_state, existing_tables):
|
|
"""Choose the appropriate migration strategy based on database state"""
|
|
logger.info(f"Choosing migration strategy for state: {db_state}")
|
|
|
|
if db_state == 'fresh':
|
|
logger.info("Fresh database detected - using standard initialization")
|
|
return 'fresh_init'
|
|
|
|
elif db_state == 'migrated':
|
|
logger.info("Database already migrated - checking for pending migrations")
|
|
return 'check_migrations'
|
|
|
|
elif db_state == 'legacy':
|
|
logger.info("Legacy database detected - using comprehensive migration")
|
|
return 'comprehensive_migration'
|
|
|
|
else:
|
|
logger.warning("Unknown database state - using comprehensive migration as fallback")
|
|
return 'comprehensive_migration'
|
|
|
|
def execute_migration_strategy(strategy, db_url):
|
|
"""Execute the chosen migration strategy"""
|
|
logger.info(f"Executing migration strategy: {strategy}")
|
|
|
|
try:
|
|
if strategy == 'fresh_init':
|
|
return execute_fresh_init(db_url)
|
|
elif strategy == 'check_migrations':
|
|
return execute_check_migrations(db_url)
|
|
elif strategy == 'comprehensive_migration':
|
|
return execute_comprehensive_migration(db_url)
|
|
else:
|
|
logger.error(f"Unknown migration strategy: {strategy}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error executing migration strategy: {e}")
|
|
return False
|
|
|
|
def execute_fresh_init(db_url):
|
|
"""Execute fresh database initialization"""
|
|
logger.info("Executing fresh database initialization...")
|
|
|
|
try:
|
|
# Initialize Flask-Migrate
|
|
result = subprocess.run(['flask', 'db', 'init'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Flask-Migrate initialized")
|
|
|
|
# Create initial migration
|
|
result = subprocess.run(['flask', 'db', 'migrate', '-m', 'Initial database schema'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Initial migration created")
|
|
|
|
# Apply migration
|
|
result = subprocess.run(['flask', 'db', 'upgrade'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Initial migration applied")
|
|
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f"Fresh init failed: {e}")
|
|
logger.error(f"STDOUT: {e.stdout}")
|
|
logger.error(f"STDERR: {e.stderr}")
|
|
return False
|
|
|
|
def execute_check_migrations(db_url):
|
|
"""Check and apply any pending migrations"""
|
|
logger.info("Checking for pending migrations...")
|
|
|
|
try:
|
|
# Check current migration status
|
|
result = subprocess.run(['flask', 'db', 'current'],
|
|
capture_output=True, text=True, check=True)
|
|
current_revision = result.stdout.strip()
|
|
logger.info(f"Current migration revision: {current_revision}")
|
|
|
|
# Check for pending migrations
|
|
result = subprocess.run(['flask', 'db', 'upgrade'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Migrations checked and applied")
|
|
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f"Migration check failed: {e}")
|
|
return False
|
|
|
|
def execute_comprehensive_migration(db_url):
|
|
"""Execute comprehensive migration for legacy databases"""
|
|
logger.info("Executing comprehensive migration...")
|
|
|
|
try:
|
|
# Run the comprehensive migration script
|
|
migration_script = '/app/migrations/migrate_existing_database.py'
|
|
|
|
if os.path.exists(migration_script):
|
|
result = subprocess.run(['python', migration_script],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Comprehensive migration completed")
|
|
return True
|
|
else:
|
|
logger.warning("Comprehensive migration script not found, falling back to manual migration")
|
|
return execute_manual_migration(db_url)
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f"Comprehensive migration failed: {e}")
|
|
logger.error(f"STDOUT: {e.stdout}")
|
|
logger.error(f"STDERR: {e.stderr}")
|
|
return False
|
|
|
|
def execute_manual_migration(db_url):
|
|
"""Execute manual migration as fallback"""
|
|
logger.info("Executing manual migration fallback...")
|
|
|
|
try:
|
|
# Initialize Flask-Migrate if not already done
|
|
if not os.path.exists('/app/migrations/env.py'):
|
|
result = subprocess.run(['flask', 'db', 'init'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Flask-Migrate initialized")
|
|
|
|
# Create baseline migration
|
|
result = subprocess.run(['flask', 'db', 'migrate', '-m', 'Baseline from existing database'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Baseline migration created")
|
|
|
|
# Stamp database as current
|
|
result = subprocess.run(['flask', 'db', 'stamp', 'head'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info("✓ Database stamped as current")
|
|
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f"Manual migration failed: {e}")
|
|
return False
|
|
|
|
def verify_database_integrity(db_url):
|
|
"""Verify that the database is working correctly after migration"""
|
|
logger.info("Verifying database integrity...")
|
|
|
|
try:
|
|
# Test basic database operations
|
|
if db_url.startswith('postgresql'):
|
|
# Handle both postgresql:// and postgresql+psycopg2:// URLs
|
|
clean_url = db_url.replace('+psycopg2://', '://')
|
|
conn = psycopg2.connect(clean_url)
|
|
cursor = conn.cursor()
|
|
|
|
# Check if key tables exist
|
|
cursor.execute("""
|
|
SELECT table_name
|
|
FROM information_schema.tables
|
|
WHERE table_name IN ('users', 'projects', 'time_entries')
|
|
AND table_schema = 'public'
|
|
""")
|
|
key_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
conn.close()
|
|
|
|
if len(key_tables) >= 2: # At least users and projects
|
|
logger.info("✓ Database integrity verified")
|
|
return True
|
|
else:
|
|
logger.error(f"Missing key tables: {key_tables}")
|
|
return False
|
|
|
|
elif db_url.startswith('sqlite'):
|
|
db_file = db_url.replace('sqlite:///', '')
|
|
|
|
if not os.path.exists(db_file):
|
|
logger.error("SQLite database file not found")
|
|
return False
|
|
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
|
|
# Check if key tables exist
|
|
cursor.execute("""
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name IN ('users', 'projects', 'time_entries')
|
|
""")
|
|
key_tables = [row[0] for row in cursor.fetchall()]
|
|
|
|
conn.close()
|
|
|
|
if len(key_tables) >= 2: # At least users and projects
|
|
logger.info("✓ Database integrity verified")
|
|
return True
|
|
else:
|
|
logger.error(f"Missing key tables: {key_tables}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Database integrity check failed: {e}")
|
|
return False
|
|
|
|
return False
|
|
|
|
def main():
|
|
"""Main startup function"""
|
|
logger.info("=== TimeTracker Enhanced Startup with Migration Detection ===")
|
|
|
|
# Set environment variables
|
|
os.environ.setdefault('FLASK_APP', '/app/app.py')
|
|
|
|
# Get database URL from environment
|
|
db_url = os.getenv('DATABASE_URL')
|
|
if not db_url:
|
|
logger.error("DATABASE_URL environment variable not set")
|
|
sys.exit(1)
|
|
|
|
logger.info(f"Database URL: {db_url}")
|
|
|
|
# Wait for database to be available
|
|
if not wait_for_database(db_url):
|
|
logger.error("Failed to connect to database")
|
|
sys.exit(1)
|
|
|
|
# Detect database state
|
|
db_state, existing_tables = detect_database_state(db_url)
|
|
logger.info(f"Detected database state: {db_state} with {len(existing_tables)} tables")
|
|
|
|
# Choose migration strategy
|
|
strategy = choose_migration_strategy(db_state, existing_tables)
|
|
logger.info(f"Selected migration strategy: {strategy}")
|
|
|
|
# Execute migration strategy
|
|
if not execute_migration_strategy(strategy, db_url):
|
|
logger.error("Migration strategy execution failed")
|
|
sys.exit(1)
|
|
|
|
# Verify database integrity
|
|
if not verify_database_integrity(db_url):
|
|
logger.error("Database integrity verification failed")
|
|
sys.exit(1)
|
|
|
|
logger.info("=== Startup and Migration Complete ===")
|
|
logger.info("Database is ready for use")
|
|
|
|
# Show final migration status
|
|
try:
|
|
result = subprocess.run(['flask', 'db', 'current'],
|
|
capture_output=True, text=True, check=True)
|
|
logger.info(f"Final migration status: {result.stdout.strip()}")
|
|
except:
|
|
logger.info("Could not determine final migration status")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|