diff --git a/docker/Dockerfile b/docker/Dockerfile index 9ccb043..c25199d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,7 +8,6 @@ ENV PYTHONPATH=/app RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libpq-dev \ - postgresql-client \ curl \ && rm -rf /var/lib/apt/lists/* diff --git a/infra/helm/stonks-oracle/templates/deployments.yaml b/infra/helm/stonks-oracle/templates/deployments.yaml index 2de060a..ca3c8ba 100644 --- a/infra/helm/stonks-oracle/templates/deployments.yaml +++ b/infra/helm/stonks-oracle/templates/deployments.yaml @@ -33,17 +33,7 @@ spec: - name: run-migrations image: {{ $root.Values.image.registry }}/{{ $svc.image }}:{{ $root.Values.image.tag }} imagePullPolicy: {{ $root.Values.image.pullPolicy }} - command: ["sh", "-c"] - args: - - | - for f in $(ls /app/infra/migrations/*.sql 2>/dev/null | sort); do - echo "Applying $(basename $f)..." - PGPASSWORD="$POSTGRES_PASSWORD" psql \ - -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" \ - -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ - -f "$f" -v ON_ERROR_STOP=0 2>&1 | tail -1 || true - done - echo "Migrations complete." + command: ["python", "-m", "services.shared.migrate"] securityContext: {{- include "stonks.containerSecurityContext" $root | nindent 12 }} envFrom: diff --git a/services/shared/migrate.py b/services/shared/migrate.py new file mode 100644 index 0000000..53ece50 --- /dev/null +++ b/services/shared/migrate.py @@ -0,0 +1,77 @@ +"""Database migration runner using asyncpg. + +Applies all SQL migration files from infra/migrations/ in sorted order. +Each file is split on semicolons and executed statement-by-statement. +Idempotent — migrations use IF NOT EXISTS / CREATE OR REPLACE patterns. + +Usage: + python -m services.shared.migrate +""" +import asyncio +import glob +import logging +import os +import sys + +import asyncpg + +logger = logging.getLogger("migrate") + + +async def run_migrations() -> None: + host = os.getenv("POSTGRES_HOST", "localhost") + port = int(os.getenv("POSTGRES_PORT", "5432")) + user = os.getenv("POSTGRES_USER", "stonks") + password = os.getenv("POSTGRES_PASSWORD", "") + database = os.getenv("POSTGRES_DB", "stonks") + + migrations_dir = os.path.join( + os.path.dirname(__file__), "..", "..", "infra", "migrations" + ) + migrations_dir = os.path.normpath(migrations_dir) + + if not os.path.isdir(migrations_dir): + logger.error("Migrations directory not found: %s", migrations_dir) + sys.exit(1) + + files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql"))) + if not files: + logger.warning("No migration files found in %s", migrations_dir) + return + + logger.info("Connecting to %s@%s:%d/%s", user, host, port, database) + conn = await asyncpg.connect( + host=host, port=port, user=user, password=password, database=database + ) + + try: + for path in files: + name = os.path.basename(path) + with open(path) as f: + sql = f.read() + # Split on semicolons and execute each statement individually. + # asyncpg.execute() doesn't support multi-statement strings. + statements = [s.strip() for s in sql.split(";") if s.strip()] + try: + for stmt in statements: + await conn.execute(stmt) + logger.info(" ✓ %s (%d statements)", name, len(statements)) + except Exception as exc: + logger.warning(" ⚠ %s: %s", name, exc) + finally: + await conn.close() + + logger.info("Migrations complete (%d files)", len(files)) + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(message)s", + datefmt="%H:%M:%S", + ) + asyncio.run(run_migrations()) + + +if __name__ == "__main__": + main()