canned-prompts/service/migrations/env.py
tegwick 8a1a2426d5 Service fixes found by the first real deployment
Three defects the test suite could not have caught, because each needed a real
cluster, a mounted secret, or a live PostgreSQL.

env.py read database_url rather than resolved_database_url. `configured` was
true because a file was set, and the field it then read was empty — so Alembic
received an empty URL and the migration could never run in the cluster. The
value is also now escaped for ConfigParser interpolation, since a `%` in a
generated password would otherwise raise at credential rotation, which is the
worst time to find out.

SET ROLE opened an implicit transaction that Alembic then nested inside rather
than owning, so it never committed and leaving the connection block rolled
everything back. Alembic logged "Running upgrade" for every revision against a
database that stayed empty. SET ROLE is session-scoped, so committing
immediately ends the implicit transaction without discarding the role.

A missing optional publish-token file was treated as a hard failure. The
absence is the documented read-only posture — the secret is mounted optional
and deliberately not issued — so treating it as a fault turned an intended
state into a 500 rather than the 503 that explains it. `required` now separates
the two cases: a missing database URL still fails loudly, because there the
silence would hide a real fault.

Migrations also assume the durable owner role rather than creating objects as
the leased migration login, per the rapp-postgres database-owner boundary. The
role name is validated against an identifier pattern because SET ROLE cannot be
parameterised.

Service tests 36 -> 47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-08 08:56:59 +02:00

61 lines
2 KiB
Python

"""Alembic environment.
The database URL comes from settings, never from alembic.ini, so a migration
cannot be run against a different database than the service uses.
"""
from __future__ import annotations
from alembic import context
from sqlalchemy import engine_from_config, pool
from canned_prompts_service.db import Base, assume_owner_role
from canned_prompts_service.settings import get_settings
from canned_prompts_service import models # noqa: F401 — registers the tables
config = context.config
target_metadata = Base.metadata
settings = get_settings()
if settings.configured:
# resolved_database_url, not database_url: in the cluster the credential
# arrives as a mounted file and the plain field is empty.
#
# The value is escaped because set_main_option interpolates through
# ConfigParser, so a `%` in a generated password would otherwise raise —
# at credential rotation, which is the worst time to discover it.
config.set_main_option(
"sqlalchemy.url", settings.resolved_database_url.replace("%", "%%")
)
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
assume_owner_role(connection)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
# Belt and braces: if anything else ever touches this connection before
# Alembic, the same nesting trap reappears silently.
connection.commit()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()