canned-prompts/service/migrations/env.py

62 lines
2 KiB
Python
Raw Normal View History

CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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-06 19:57:47 +02:00
"""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
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
from canned_prompts_service.db import Base, assume_owner_role
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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-06 19:57:47 +02:00
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:
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
# 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("%", "%%")
)
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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-06 19:57:47 +02:00
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:
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
assume_owner_role(connection)
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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-06 19:57:47 +02:00
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
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
# Belt and braces: if anything else ever touches this connection before
# Alembic, the same nesting trap reappears silently.
connection.commit()
CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. CANNED_PROMPTS_DATABASE_URL has no default. A service that silently falls back to a local database when its real one is misconfigured is worse than one that refuses to start. Health surface per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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-06 19:57:47 +02:00
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()