feat: implement database migration runner and initialize migrations on startup
This commit is contained in:
@@ -29,10 +29,11 @@ async def lifespan(app: FastAPI):
|
|||||||
initialize_logging()
|
initialize_logging()
|
||||||
app_logger = get_app_logger()
|
app_logger = get_app_logger()
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database and run pending migrations before accepting traffic
|
||||||
try:
|
try:
|
||||||
|
app_logger.info(f"Initializing database at: {config.database_path}")
|
||||||
initialize_database(config.database_path)
|
initialize_database(config.database_path)
|
||||||
app_logger.info(f"Database initialized at: {config.database_path}")
|
app_logger.info("Database ready")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_logger.warning(
|
app_logger.warning(
|
||||||
f"Database initialization failed: {e}. Continuing with in-memory only."
|
f"Database initialization failed: {e}. Continuing with in-memory only."
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ class DatabaseManager:
|
|||||||
# Run automatic migrations for backward compatibility
|
# Run automatic migrations for backward compatibility
|
||||||
self._run_migrations(database_path)
|
self._run_migrations(database_path)
|
||||||
|
|
||||||
|
# Run schema migrations (columns & indexes on existing tables)
|
||||||
|
from migrations.runner import run_migrations
|
||||||
|
|
||||||
|
run_migrations(database_path)
|
||||||
|
|
||||||
# Set restrictive file permissions (owner read/write only)
|
# Set restrictive file permissions (owner read/write only)
|
||||||
if os.path.exists(database_path):
|
if os.path.exists(database_path):
|
||||||
try:
|
try:
|
||||||
|
|||||||
0
src/migrations/__init__.py
Normal file
0
src/migrations/__init__.py
Normal file
94
src/migrations/runner.py
Normal file
94
src/migrations/runner.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Migration runner for Krawl.
|
||||||
|
Checks the database schema and applies any pending migrations at startup.
|
||||||
|
All checks are idempotent — safe to run on every boot.
|
||||||
|
|
||||||
|
Note: table creation (e.g. category_history) is already handled by
|
||||||
|
Base.metadata.create_all() in DatabaseManager.initialize() and is NOT
|
||||||
|
duplicated here. This runner only covers ALTER-level changes that
|
||||||
|
create_all() cannot apply to existing tables (new columns, new indexes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
logger = logging.getLogger("krawl")
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(cursor, table_name: str, column_name: str) -> bool:
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(cursor, index_name: str) -> bool:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
|
||||||
|
(index_name,),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_raw_request_column(cursor) -> bool:
|
||||||
|
"""Add raw_request column to access_logs if missing."""
|
||||||
|
if _column_exists(cursor, "access_logs", "raw_request"):
|
||||||
|
return False
|
||||||
|
cursor.execute("ALTER TABLE access_logs ADD COLUMN raw_request TEXT")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_performance_indexes(cursor) -> List[str]:
|
||||||
|
"""Add performance indexes to attack_detections if missing."""
|
||||||
|
added = []
|
||||||
|
if not _index_exists(cursor, "ix_attack_detections_attack_type"):
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX ix_attack_detections_attack_type "
|
||||||
|
"ON attack_detections(attack_type)"
|
||||||
|
)
|
||||||
|
added.append("ix_attack_detections_attack_type")
|
||||||
|
|
||||||
|
if not _index_exists(cursor, "ix_attack_detections_type_log"):
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX ix_attack_detections_type_log "
|
||||||
|
"ON attack_detections(attack_type, access_log_id)"
|
||||||
|
)
|
||||||
|
added.append("ix_attack_detections_type_log")
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(database_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Check the database schema and apply any pending migrations.
|
||||||
|
|
||||||
|
Only handles ALTER-level changes (columns, indexes) that
|
||||||
|
Base.metadata.create_all() cannot apply to existing tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to the SQLite database file.
|
||||||
|
"""
|
||||||
|
applied: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(database_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if _migrate_raw_request_column(cursor):
|
||||||
|
applied.append("add raw_request column to access_logs")
|
||||||
|
|
||||||
|
idx_added = _migrate_performance_indexes(cursor)
|
||||||
|
for idx in idx_added:
|
||||||
|
applied.append(f"add index {idx}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error(f"Migration error: {e}")
|
||||||
|
|
||||||
|
if applied:
|
||||||
|
for m in applied:
|
||||||
|
logger.info(f"Migration applied: {m}")
|
||||||
|
logger.info(f"All migrations complete ({len(applied)} applied)")
|
||||||
|
else:
|
||||||
|
logger.info("Database schema is up to date — no migrations needed")
|
||||||
Reference in New Issue
Block a user