Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/runbooks/strr-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Flask backend for STRR. Source: `strr-api/`.
- When `POD_NAMESPACE == "migration"`, the app factory registers **only** Flask-Migrate (no normal HTTP routes) (`strr-api/src/strr_api/__init__.py`).
- **Driver note:** Migrations may use `postgresql+pg8000` while runtime uses `postgresql`/psycopg2. See `config.py` and `migrations/env.py`.
- Standalone Alembic (no Flask context) uses `DATABASE_URL` with `NullPool` in `migrations/env.py`.
- Optional ownership handoff: set `DATABASE_OWNER_ROLE` so online Alembic migrations run with that DB role and new objects are owned by it. The migration user must be able to `SET ROLE`, and that role needs schema create privileges.

## Scaling (prod)

Expand Down
3 changes: 3 additions & 0 deletions strr-api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ DATABASE_PASSWORD=postgres
DATABASE_NAME=postgres
DATABASE_HOST=localhost
DATABASE_PORT=15432
# Optional: Alembic runs migrations with this DB role so new objects are owned by it.
# For Cloud SQL IAM auth, set this to the service account's Postgres role name.
DATABASE_OWNER_ROLE=

## TEST DB
DATABASE_TEST_USERNAME=postgres
Expand Down
15 changes: 12 additions & 3 deletions strr-api/migrations/env.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import logging
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool, create_engine

from alembic import context
from sqlalchemy import create_engine, pool, text

# 1. Passive Detection: Don't create an app, just look for one
try:
Expand Down Expand Up @@ -40,14 +41,22 @@ def get_engine_url():
def get_metadata():
return target_metadata


config.set_main_option('sqlalchemy.url', get_engine_url())


def run_migrations_online():
connectable = get_engine()
with connectable.connect() as connection:
owner_role = os.getenv("DATABASE_OWNER_ROLE")
if owner_role:
safe_role = owner_role.replace('"', '""') # Escape any quotes for SQL safety
connection.execute(text(f'SET ROLE "{safe_role}"'))
connection.commit()

# Only pull migrate args if Flask is actually present
extra_args = current_app.extensions['migrate'].configure_args if use_flask else {}

context.configure(
connection=connection,
target_metadata=get_metadata(),
Expand Down
66 changes: 66 additions & 0 deletions strr-api/tests/integration/test_alembic_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Integration tests for standalone Alembic migration behavior."""

from pathlib import Path

from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
from testcontainers.postgres import PostgresContainer


def test_alembic_runs_with_configured_owner_role(monkeypatch):
"""Alembic runs migrations with the configured DB owner role."""
api_root = Path(__file__).resolve().parents[2]
migrations_path = api_root / "migrations"
owner = "sa-api@bcrbk9-test.iam"

with PostgresContainer("postgres:16-alpine") as postgres:
db_url = postgres.get_connection_url()
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("DATABASE_OWNER_ROLE", owner)

engine = create_engine(db_url)
with engine.begin() as conn:
quoted_owner = conn.dialect.identifier_preparer.quote(owner)
conn.execute(text(f"CREATE ROLE {quoted_owner} LOGIN"))
conn.execute(text(f"GRANT USAGE, CREATE ON SCHEMA public TO {quoted_owner}"))

cfg = Config(str(migrations_path / "alembic.ini"))
cfg.set_main_option("script_location", str(migrations_path))
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")

with engine.connect() as conn:
class_mismatches = conn.execute(
text(
"""
SELECT c.relkind, n.nspname, c.relname, pg_get_userbyid(c.relowner) AS owner
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind IN ('r', 'p', 'S', 'v', 'm', 'f')
AND pg_get_userbyid(c.relowner) != :owner
ORDER BY c.relkind, c.relname
"""
),
{"owner": owner},
).fetchall()
type_mismatches = conn.execute(
text(
"""
SELECT n.nspname, t.typname, pg_get_userbyid(t.typowner) AS owner
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
AND t.typtype IN ('d', 'e')
AND pg_get_userbyid(t.typowner) != :owner
ORDER BY t.typname
"""
),
{"owner": owner},
).fetchall()
current_role = conn.execute(text("SELECT current_user")).scalar_one()

assert class_mismatches == []
assert type_mismatches == []
assert current_role != owner
Loading