- Multi-stage Dockerfile (builder+runner, <500MB target) - docker-compose.yml: app(x4) + postgres + redis + prometheus + grafana + nginx - .env.example with all required secrets (never hardcoded) - requirements.txt with all dependencies including prometheus-client, alembic - GitHub Actions CI: lint (flake8+bandit+safety) + tests + Docker build/push - GitHub Actions CD: staging deploy -> smoke tests -> production deploy + rollback - Alembic migration setup + initial PostgreSQL schema (001_initial_schema) - SQLite→PostgreSQL data migration script - Prometheus metrics module (HTTP, ML, DB, business metrics) - Prometheus alert rules (5xx >1%, latency >2s, disk >80%, ML accuracy) - Grafana dashboard (overview: req/s, p95, ML accuracy, error rate) - Nginx reverse proxy config (HTTPS/TLS, rate limiting, security headers) - Structured JSON logging module - Automated daily DB backup script (pg_dump + 30-day retention) Branch: feature/devops-cicd Co-Authored-By: Paperclip <noreply@paperclip.ing>
181 lines
5.0 KiB
Python
181 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SQLite → PostgreSQL Data Migration Script
|
|
Migrates existing turf_saas.db data to PostgreSQL.
|
|
|
|
Usage:
|
|
python migrations/migrate_sqlite_to_postgres.py \
|
|
--sqlite /path/to/turf_saas.db \
|
|
--pg-url postgresql://turf:password@localhost:5432/turf_saas
|
|
|
|
Run AFTER alembic upgrade head.
|
|
"""
|
|
|
|
import argparse
|
|
import sqlite3
|
|
import sys
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger("migrate")
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
|
|
|
|
# Tables to migrate (in order to respect FK constraints)
|
|
TABLES = [
|
|
"predictions",
|
|
"results",
|
|
"performance",
|
|
"scraping_logs",
|
|
"pmu_reunions",
|
|
"pmu_meteo",
|
|
"pmu_courses",
|
|
"pmu_partants",
|
|
"ml_predictions_cache",
|
|
"users",
|
|
"subscriptions",
|
|
"refresh_tokens",
|
|
]
|
|
|
|
|
|
def get_sqlite_conn(sqlite_path: str):
|
|
conn = sqlite3.connect(sqlite_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def get_pg_conn(pg_url: str):
|
|
try:
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
conn = psycopg2.connect(pg_url)
|
|
return conn
|
|
except ImportError:
|
|
logger.error("psycopg2 not installed. Run: pip install psycopg2-binary")
|
|
sys.exit(1)
|
|
|
|
|
|
def migrate_table(sqlite_conn, pg_conn, table: str, batch_size: int = 500) -> int:
|
|
"""Migrate a single table from SQLite to PostgreSQL. Returns row count."""
|
|
import psycopg2.extras
|
|
|
|
sqlite_cur = sqlite_conn.cursor()
|
|
pg_cur = pg_conn.cursor()
|
|
|
|
# Get rows from SQLite
|
|
try:
|
|
sqlite_cur.execute(f"SELECT * FROM {table}")
|
|
except Exception as e:
|
|
logger.warning(f" Skipping {table}: {e}")
|
|
return 0
|
|
|
|
rows = sqlite_cur.fetchall()
|
|
if not rows:
|
|
logger.info(f" {table}: empty — skipping")
|
|
return 0
|
|
|
|
# Get column names
|
|
columns = [desc[0] for desc in sqlite_cur.description]
|
|
# Exclude 'id' to let PostgreSQL generate SERIAL
|
|
non_id_columns = [c for c in columns if c != "id"]
|
|
|
|
if not non_id_columns:
|
|
logger.warning(f" {table}: no columns to insert")
|
|
return 0
|
|
|
|
placeholders = ", ".join(["%s"] * len(non_id_columns))
|
|
col_list = ", ".join(non_id_columns)
|
|
insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
|
|
|
inserted = 0
|
|
batch = []
|
|
|
|
for row in rows:
|
|
row_dict = dict(row)
|
|
values = tuple(row_dict.get(c) for c in non_id_columns)
|
|
batch.append(values)
|
|
|
|
if len(batch) >= batch_size:
|
|
try:
|
|
pg_cur.executemany(insert_sql, batch)
|
|
pg_conn.commit()
|
|
inserted += len(batch)
|
|
except Exception as e:
|
|
pg_conn.rollback()
|
|
logger.error(f" {table} batch error: {e}")
|
|
batch = []
|
|
|
|
# Final batch
|
|
if batch:
|
|
try:
|
|
pg_cur.executemany(insert_sql, batch)
|
|
pg_conn.commit()
|
|
inserted += len(batch)
|
|
except Exception as e:
|
|
pg_conn.rollback()
|
|
logger.error(f" {table} final batch error: {e}")
|
|
|
|
# Sync PostgreSQL sequence to max id
|
|
try:
|
|
pg_cur.execute(f"SELECT MAX(id) FROM {table}")
|
|
max_id = pg_cur.fetchone()[0]
|
|
if max_id:
|
|
seq_name = f"{table}_id_seq"
|
|
pg_cur.execute(f"SELECT setval('{seq_name}', {max_id})")
|
|
pg_conn.commit()
|
|
except Exception:
|
|
pass # Table may not have a sequence
|
|
|
|
return inserted
|
|
|
|
|
|
def run_migration(sqlite_path: str, pg_url: str):
|
|
logger.info(f"=== SQLite → PostgreSQL Migration ===")
|
|
logger.info(f"SQLite: {sqlite_path}")
|
|
logger.info(f"PostgreSQL: {pg_url.split('@')[-1]}") # Hide credentials in log
|
|
logger.info(f"Started: {datetime.now().isoformat()}")
|
|
|
|
if not os.path.exists(sqlite_path):
|
|
logger.error(f"SQLite file not found: {sqlite_path}")
|
|
sys.exit(1)
|
|
|
|
sqlite_conn = get_sqlite_conn(sqlite_path)
|
|
pg_conn = get_pg_conn(pg_url)
|
|
|
|
total = 0
|
|
for table in TABLES:
|
|
logger.info(f" Migrating: {table}...")
|
|
count = migrate_table(sqlite_conn, pg_conn, table)
|
|
logger.info(f" → {table}: {count} rows migrated")
|
|
total += count
|
|
|
|
sqlite_conn.close()
|
|
pg_conn.close()
|
|
|
|
logger.info(f"=== Migration complete: {total} total rows ===")
|
|
logger.info(f"Finished: {datetime.now().isoformat()}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Migrate SQLite → PostgreSQL")
|
|
parser.add_argument(
|
|
"--sqlite",
|
|
default=os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf_saas.db"),
|
|
help="Path to SQLite database file",
|
|
)
|
|
parser.add_argument(
|
|
"--pg-url",
|
|
default=os.environ.get("DATABASE_URL", ""),
|
|
help="PostgreSQL connection URL",
|
|
)
|
|
parser.add_argument("--batch-size", type=int, default=500)
|
|
args = parser.parse_args()
|
|
|
|
if not args.pg_url:
|
|
logger.error("--pg-url or DATABASE_URL env var required")
|
|
sys.exit(1)
|
|
|
|
run_migration(args.sqlite, args.pg_url)
|