alembic fix

This commit is contained in:
2026-06-01 16:34:07 +03:00
parent e12a9ddfce
commit 4c5da7e4d9
15 changed files with 346 additions and 90 deletions

View File

@@ -86,7 +86,10 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = sqlite:///./alembic_test.db # H1: Placeholder — DATABASE_URL env var overrides this in alembic/env.py.
# If DATABASE_URL is not set, Alembic will fail with a clear error instead of
# silently operating on a SQLite test database.
sqlalchemy.url = postgresql+psycopg2://__MUST_SET_DATABASE_URL__:5432/__db__
[post_write_hooks] [post_write_hooks]

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '7703bbc038bd' revision: str = '7703bbc038bd'
@@ -17,8 +18,16 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Add description column to validation_policies.""" """Add description column to validation_policies."""
if not _table_exists("validation_policies"):
return
op.add_column('validation_policies', sa.Column('description', sa.Text(), nullable=True)) op.add_column('validation_policies', sa.Column('description', sa.Text(), nullable=True))

View File

@@ -7,9 +7,9 @@ Create Date: 2026-05-26 15:27:06.159151
""" """
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '86c7b1d6a710' revision: str = '86c7b1d6a710'
@@ -18,8 +18,16 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Add is_admin column to roles table, set True for existing Admin roles.""" """Add is_admin column to roles table, set True for existing Admin roles."""
if not _table_exists("roles"):
return
op.add_column("roles", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false"))) op.add_column("roles", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.execute("UPDATE roles SET is_admin = true WHERE name = 'Admin'") op.execute("UPDATE roles SET is_admin = true WHERE name = 'Admin'")

View File

@@ -11,6 +11,7 @@ from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "9f8e7d6c5b4a" revision: str = "9f8e7d6c5b4a"
@@ -55,8 +56,22 @@ def _is_multimodal_heuristic(model_name: str) -> bool:
return any(marker in token for marker in multimodal_markers) return any(marker in token for marker in multimodal_markers)
def _table_exists(table_name: str) -> bool:
"""Check if a table exists in the current database schema."""
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Add is_multimodal column and backfill using heuristic.""" """Add is_multimodal column and backfill using heuristic."""
# The llm_providers table is defined in the ORM model (src/models/llm.py)
# and created by Base.metadata.create_all() at app startup, not via migrations.
# If the table doesn't exist yet (fresh DB), skip — the model definition
# already includes is_multimodal, so create_all() will create it with the column.
if not _table_exists("llm_providers"):
return
# Step 1: Add column as nullable first # Step 1: Add column as nullable first
op.add_column( op.add_column(
"llm_providers", "llm_providers",

View File

@@ -8,9 +8,9 @@ Create Date: 2026-05-21 10:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "a7b1c2d3e4f5" revision: str = "a7b1c2d3e4f5"
@@ -19,8 +19,16 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Add provider_id column to validation_policies as nullable.""" """Add provider_id column to validation_policies as nullable."""
if not _table_exists("validation_policies"):
return
op.add_column( op.add_column(
"validation_policies", "validation_policies",
sa.Column("provider_id", sa.String(), nullable=True), sa.Column("provider_id", sa.String(), nullable=True),

View File

@@ -22,9 +22,9 @@ Create Date: 2026-05-31 12:00:00.000000
""" """
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "c9d8e7f6a5b4" revision: str = "c9d8e7f6a5b4"
@@ -33,6 +33,12 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema to v2 validation models.""" """Upgrade schema to v2 validation models."""
_create_validation_sources() _create_validation_sources()
@@ -115,6 +121,8 @@ def downgrade() -> None:
def _create_validation_sources() -> None: def _create_validation_sources() -> None:
"""Create the validation_sources table (no FK dependencies).""" """Create the validation_sources table (no FK dependencies)."""
if not _table_exists("validation_policies"):
return
op.create_table( op.create_table(
"validation_sources", "validation_sources",
sa.Column("id", sa.String(), nullable=False), sa.Column("id", sa.String(), nullable=False),
@@ -150,6 +158,8 @@ def _create_validation_sources() -> None:
def _create_validation_runs() -> None: def _create_validation_runs() -> None:
"""Create the validation_runs table.""" """Create the validation_runs table."""
if not _table_exists("validation_policies"):
return
op.create_table( op.create_table(
"validation_runs", "validation_runs",
sa.Column("id", sa.String(), nullable=False), sa.Column("id", sa.String(), nullable=False),
@@ -207,6 +217,8 @@ def _create_validation_runs() -> None:
def _extend_validation_policies() -> None: def _extend_validation_policies() -> None:
"""Add v2 columns and index to validation_policies.""" """Add v2 columns and index to validation_policies."""
if not _table_exists("validation_policies"):
return
op.add_column( op.add_column(
"validation_policies", "validation_policies",
sa.Column("prompt_template", sa.Text(), nullable=True), sa.Column("prompt_template", sa.Text(), nullable=True),
@@ -269,6 +281,8 @@ def _extend_validation_policies() -> None:
def _extend_validation_results() -> None: def _extend_validation_results() -> None:
"""Add v2 columns, FKs, and indexes to llm_validation_results.""" """Add v2 columns, FKs, and indexes to llm_validation_results."""
if not _table_exists("llm_validation_results"):
return
op.add_column( op.add_column(
"llm_validation_results", "llm_validation_results",
sa.Column( sa.Column(
@@ -335,6 +349,8 @@ def _backfill_validation_runs() -> None:
Safe no-op when no such rows exist (e.g. fresh database). Safe no-op when no such rows exist (e.g. fresh database).
""" """
if not _table_exists("llm_validation_results"):
return
conn = op.get_bind() conn = op.get_bind()
# Check for existing records that need backfill # Check for existing records that need backfill

View File

@@ -8,9 +8,9 @@ Create Date: 2026-05-21 12:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "e5f4d3c2b1a" revision: str = "e5f4d3c2b1a"
@@ -19,8 +19,16 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def upgrade() -> None: def upgrade() -> None:
"""Add policy_id column to llm_validation_results as nullable indexed.""" """Add policy_id column to llm_validation_results as nullable indexed."""
if not _table_exists("llm_validation_results"):
return
op.add_column( op.add_column(
"llm_validation_results", "llm_validation_results",
sa.Column("policy_id", sa.String(), nullable=True, index=True), sa.Column("policy_id", sa.String(), nullable=True, index=True),

View File

@@ -9,6 +9,7 @@ Create Date: 2026-05-31 22:19:21.922928
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa import sqlalchemy as sa
@@ -19,6 +20,14 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# The llm_providers table is defined in the ORM model and created by
# Base.metadata.create_all() at app startup, not via migrations.
# If the table doesn't exist yet (fresh DB), skip — the model definition
# already includes max_images, so create_all() will create it with the column.
conn = op.get_bind()
inspector = inspect(conn)
if "llm_providers" not in inspector.get_table_names():
return
op.add_column('llm_providers', sa.Column('max_images', sa.Integer(), nullable=True)) op.add_column('llm_providers', sa.Column('max_images', sa.Integer(), nullable=True))

View File

@@ -18,7 +18,6 @@ from pathlib import Path
# project_root is used for static files mounting # project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent project_root = Path(__file__).resolve().parent.parent.parent
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
@@ -27,8 +26,6 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from typing import Any from typing import Any
from alembic import command as alembic_command
from .api import auth from .api import auth
from .api.routes import ( from .api.routes import (
admin, admin,
@@ -67,10 +64,14 @@ from .models.auth import Role, User
# #region lifespan [C:3] [TYPE Function] # #region lifespan [C:3] [TYPE Function]
# @BRIEF Async context manager for FastAPI startup/shutdown lifecycle. # @BRIEF Async context manager for FastAPI startup/shutdown lifecycle.
# @RELATION CALLS -> [run_alembic_migrations]
# @RELATION CALLS -> [init_db] # @RELATION CALLS -> [init_db]
# @RELATION CALLS -> [AppDependencies] # @RELATION CALLS -> [AppDependencies]
# @POST On startup: schema up-to-date, admin exists, scheduler started. On shutdown: scheduler stopped. # @POST On startup: admin exists, scheduler started. On shutdown: scheduler stopped.
# @RATIONALE Alembic migrations removed from lifespan — they now run exclusively
# in docker/backend.entrypoint.sh (wait_for_db → alembic upgrade head).
# Running migrations in both places added ~5s startup overhead and masked
# partial failures. init_db() remains as a safety net for tables without
# dedicated Alembic migrations (e.g., newly added models during development).
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
@@ -78,9 +79,7 @@ async def lifespan(app: FastAPI):
with belief_scope("startup_event"): with belief_scope("startup_event"):
logger.info("🔐 Ensuring encryption key...") logger.info("🔐 Ensuring encryption key...")
ensure_encryption_key() ensure_encryption_key()
logger.info("📦 Running Alembic migrations...") logger.info("🗄️ Initializing database tables (safety net)...")
run_alembic_migrations()
logger.info("🗄️ Initializing database tables...")
init_db() init_db()
logger.info("👤 Bootstrapping admin user...") logger.info("👤 Bootstrapping admin user...")
ensure_initial_admin_user() ensure_initial_admin_user()
@@ -109,7 +108,6 @@ async def lifespan(app: FastAPI):
scheduler = get_scheduler_service() scheduler = get_scheduler_service()
scheduler.start() scheduler.start()
logger.info("✅ Application startup complete") logger.info("✅ Application startup complete")
logger.info("✅ Application startup complete")
yield yield
# Shutdown # Shutdown
scheduler.stop() scheduler.stop()
@@ -185,13 +183,16 @@ def ensure_initial_admin_user() -> None:
# #endregion ensure_initial_admin_user # #endregion ensure_initial_admin_user
# #region run_alembic_migrations [C:2] [TYPE Function] # #region run_alembic_migrations [C:2] [TYPE Function]
# @BRIEF Applies all pending Alembic migrations against DATABASE_URL. # @BRIEF Applies all pending Alembic migrations against DATABASE_URL.
# DEPRECATED: Migrations now run exclusively in docker/backend.entrypoint.sh.
# Kept for local development (manual invocation).
# @POST All Alembic migrations up to 'head' are applied. # @POST All Alembic migrations up to 'head' are applied.
# @SIDE_EFFECT Executes ALTER TABLE / CREATE TABLE via Alembic chain. # @SIDE_EFFECT Executes ALTER TABLE / CREATE TABLE via Alembic chain.
# @RATIONALE Single source of truth for schema changes. All column additions, # @DEPRECATED Migrations moved to entrypoint.sh — this function is kept for
# drops, renames, and data migrations go through Alembic. # local development only. Do NOT call from lifespan or production code.
# init_db() runs after to create any tables that don't have
# a standalone Alembic migration yet.
def run_alembic_migrations() -> None: def run_alembic_migrations() -> None:
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command
with belief_scope("run_alembic_migrations"): with belief_scope("run_alembic_migrations"):
try: try:
alembic_cfg = AlembicConfig("alembic.ini") alembic_cfg = AlembicConfig("alembic.ini")

View File

@@ -760,15 +760,17 @@ def _ensure_dictionary_entries_columns(bind_engine):
# #region init_db [C:3] [TYPE Function] # #region init_db [C:3] [TYPE Function]
# @BRIEF Creates any missing tables via create_all(). # @BRIEF Creates any missing tables via create_all() — safety net for development.
# @PRE engine, tasks_engine and auth_engine are initialized. # @PRE engine, tasks_engine and auth_engine are initialized.
# Alembic migrations (run_alembic_migrations) have already been applied. # In Docker: Alembic migrations already applied via entrypoint.sh.
# @POST All tables from SQLAlchemy models exist in all databases. # @POST All tables from SQLAlchemy models exist in all databases.
# @SIDE_EFFECT Creates physical database tables if they don't exist. # @SIDE_EFFECT Creates physical database tables if they don't exist.
# @RATIONALE Schema migrations are handled by Alembic (run_alembic_migrations runs # @RATIONALE create_all() is idempotent — it only creates tables that don't exist,
# before init_db in startup_event). create_all() only creates NEW tables # never alters existing ones. In Docker, Alembic runs first in entrypoint.sh
# that don't exist yet — it does NOT alter existing ones. All column # (wait_for_db → alembic upgrade head), so init_db() is a no-op for production.
# additions must go through Alembic migrations. # For local development (run.sh without Docker), this provides a safety net.
# @REJECTED Removing init_db() entirely was rejected — local development without
# Docker would require manual Alembic setup, increasing friction for developers.
# @REJECTED _ensure_*() inline additive migrations removed — they were duplicating # @REJECTED _ensure_*() inline additive migrations removed — they were duplicating
# Alembic logic, were not versioned, and created hidden schema drift. # Alembic logic, were not versioned, and created hidden schema drift.
# All schema changes must now go through Alembic. # All schema changes must now go through Alembic.

View File

@@ -0,0 +1,114 @@
# #region check_migration_chain [C:3] [TYPE Module] [SEMANTICS alembic,migration,validation,ci]
# @BRIEF Validates Alembic migration chain integrity — checks for multiple heads,
# broken chains, and missing down_revision references.
# @RATIONALE Merge migrations (e.g., f0e9d8c7b6a5) are fragile — a new migration
# with wrong down_revision can create multiple heads and break `alembic upgrade head`.
# This script catches the issue before deployment.
# @REJECTED Relying on `alembic upgrade head` to fail at deploy time was rejected —
# it's better to catch migration chain issues in CI or pre-commit.
import sys
from pathlib import Path
# Ensure backend/src is importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# #region check_migration_heads [C:2] [TYPE Function]
# @BRIEF Checks that there is exactly one migration head.
# @POST Prints error and exits 1 if multiple heads found.
def check_migration_heads() -> None:
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
alembic_cfg = AlembicConfig("alembic.ini")
alembic_cfg.set_main_option("script_location", "alembic")
script_dir = ScriptDirectory.from_config(alembic_cfg)
heads = list(script_dir.get_heads())
if len(heads) == 0:
print("[migration-check] ERROR: No migration heads found — chain is empty")
sys.exit(1)
if len(heads) > 1:
print(f"[migration-check] ERROR: Multiple migration heads detected ({len(heads)}):")
for h in heads:
rev = script_dir.get_revision(h)
print(f" - {h}: {rev.doc}")
print("[migration-check] Fix: ensure new migrations reference the correct parent")
sys.exit(1)
print(f"[migration-check] OK: Single migration head — {heads[0]}")
# #endregion check_migration_heads
# #region check_migration_chain_integrity [C:2] [TYPE Function]
# @BRIEF Walks the migration chain from head to root, checking all down_revision links exist.
# @POST Prints error and exits 1 if a broken link is found.
def check_migration_chain_integrity() -> None:
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
alembic_cfg = AlembicConfig("alembic.ini")
alembic_cfg.set_main_option("script_location", "alembic")
script_dir = ScriptDirectory.from_config(alembic_cfg)
heads = list(script_dir.get_heads())
if not heads:
return # already checked above
visited = set()
current_revs = heads
while current_revs:
next_revs = []
for rev_id in current_revs:
if rev_id in visited:
continue
visited.add(rev_id)
rev = script_dir.get_revision(rev_id)
down = rev.down_revision
if down is None:
continue # root migration
if isinstance(down, str):
try:
script_dir.get_revision(down)
except Exception:
print(f"[migration-check] ERROR: Broken link — {rev_id} -> {down} (not found)")
sys.exit(1)
next_revs.append(down)
elif isinstance(down, tuple):
# Merge migration — check all parents exist
for parent in down:
try:
script_dir.get_revision(parent)
except Exception:
print(f"[migration-check] ERROR: Broken merge link — {rev_id} -> {parent} (not found)")
sys.exit(1)
next_revs.append(parent)
current_revs = next_revs
print(f"[migration-check] OK: Chain integrity verified — {len(visited)} migrations traversed")
# #endregion check_migration_chain_integrity
# #region check_migration_main [C:2] [TYPE Function]
# @BRIEF Entry point — runs all migration chain checks.
def main() -> None:
print("[migration-check] === Alembic Migration Chain Validation ===")
check_migration_heads()
check_migration_chain_integrity()
print("[migration-check] === All checks passed ===")
# #endregion check_migration_main
if __name__ == "__main__":
main()

View File

@@ -1,16 +1,17 @@
# #region TestAlembicMigrations [C:3] [TYPE Module] [SEMANTICS tests, alembic, migration, schema, idempotent] # #region TestAlembicMigrations [C:3] [TYPE Module] [SEMANTICS tests, alembic, migration, schema, idempotent]
# @BRIEF Verifies Alembic migration chain: fresh upgrade and legacy stamp. # @BRIEF Verifies Alembic migration chain: fresh upgrade and legacy upgrade.
# @RELATION BINDS_TO -> [ed310b33f02c] # @RELATION BINDS_TO -> [ed310b33f02c]
# @INVARIANT Running `alembic upgrade head` twice is idempotent (no-op on second run) on PostgreSQL. # @INVARIANT Running `alembic upgrade head` twice is idempotent (no-op on second run) on PostgreSQL.
# @INVARIANT Legacy database with existing tables can be stamped after adding boot-critical columns. # @INVARIANT Legacy database with existing tables can be upgraded via `alembic upgrade head`.
# @PRE Alembic is installed, migration files exist in alembic/versions/. # @PRE Alembic is installed, migration files exist in alembic/versions/.
# @POST Schema matches expected state after upgrade. # @POST Schema matches expected state after upgrade.
# @TEST_INVARIANT: alembic_fresh_upgrade -> VERIFIED_BY: [test_fresh_upgrade_creates_tables] # @TEST_INVARIANT: alembic_fresh_upgrade -> VERIFIED_BY: [test_fresh_upgrade_creates_tables]
# @TEST_INVARIANT: alembic_stamp_legacy -> VERIFIED_BY: [test_legacy_database_stamp] # @TEST_INVARIANT: alembic_upgrade_legacy -> VERIFIED_BY: [test_legacy_database_upgrade]
# @REJECTED Full migration chain idempotency on SQLite — initial migration uses # @REJECTED Full migration chain idempotency on SQLite — initial migration uses
# op.create_index() which conflicts with SQLite auto-created indexes # op.create_index() which conflicts with SQLite auto-created indexes
# for UNIQUE constraints. Migrations are designed for PostgreSQL. # for UNIQUE constraints. Migrations are designed for PostgreSQL.
# Legacy databases use stamp + selective raw SQL in entrypoint instead. # @REJECTED Legacy stamp + raw SQL rejected — entrypoint now runs `alembic upgrade head`
# for legacy databases, ensuring all missing tables/columns are created.
import os import os
import sys import sys
@@ -65,51 +66,43 @@ def test_fresh_upgrade_creates_tables() -> None:
# #endregion test_fresh_upgrade_creates_tables # #endregion test_fresh_upgrade_creates_tables
# #region test_legacy_database_stamp [C:2] [TYPE Function] # #region test_legacy_database_upgrade [C:2] [TYPE Function]
# @BRIEF Pre-populate tables via create_all(), then verify entrypoint-style stamp works. # @BRIEF Simulate legacy DB (tables exist, no alembic_version), verify upgrade head works.
# @POST alembic_version created, is_admin column present after stamp + raw SQL. # @POST alembic_version created, all migration columns present after upgrade.
# @TEST_INVARIANT: alembic_upgrade_legacy -> VERIFIED_BY: [test_legacy_database_upgrade]
@_REQUIRES_PG @_REQUIRES_PG
def test_legacy_database_stamp() -> None: def test_legacy_database_upgrade() -> None:
"""Alembic stamp works on a database that already has tables (PostgreSQL only).""" """Alembic upgrade head works on a database that already has tables but no alembic_version."""
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from src.models.mapping import Base from src.models.mapping import Base
from src.models import auth as _auth # noqa: F401 from src.models import auth as _auth # noqa: F401
from src.models import task as _task # noqa: F401 from src.models import task as _task # noqa: F401
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command
engine = create_engine(os.environ["DATABASE_URL"]) engine = create_engine(os.environ["DATABASE_URL"])
# Drop alembic_version if exists to simulate legacy state # Drop alembic_version if exists to simulate legacy state
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(sa_text("DROP TABLE IF EXISTS alembic_version CASCADE")) conn.execute(sa_text("DROP TABLE IF EXISTS alembic_version CASCADE"))
engine.dispose() engine.dispose()
# Tables already exist from previous runs (create_all)
# Add is_admin column (simulating entrypoint raw SQL) # Tables already exist from previous runs (create_all) — this is the legacy state.
engine = create_engine(os.environ["DATABASE_URL"]) # Run upgrade head (new entrypoint behavior for legacy DB)
with engine.begin() as conn: _run_alembic_upgrade()
inspector = inspect(conn)
roles_cols = {c["name"] for c in inspector.get_columns("roles")}
if "is_admin" not in roles_cols:
conn.execute(
sa_text("ALTER TABLE roles ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
)
conn.execute(sa_text("UPDATE roles SET is_admin = TRUE WHERE name = 'Admin'"))
engine.dispose()
# Stamp head (entrypoint behavior for legacy DB)
backend_dir = Path(__file__).parent.parent
alembic_cfg = AlembicConfig(str(backend_dir / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
alembic_command.stamp(alembic_cfg, "head")
# Verify # Verify
engine = create_engine(os.environ["DATABASE_URL"]) engine = create_engine(os.environ["DATABASE_URL"])
inspector = inspect(engine) inspector = inspect(engine)
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
assert "alembic_version" in tables, "alembic_version table not created by stamp" assert "alembic_version" in tables, "alembic_version table not created by upgrade"
roles_cols = {c["name"] for c in inspector.get_columns("roles")} roles_cols = {c["name"] for c in inspector.get_columns("roles")}
assert "is_admin" in roles_cols assert "is_admin" in roles_cols, "roles.is_admin column missing after legacy upgrade"
# Verify version_num matches head
result = engine.connect().execute(sa_text("SELECT version_num FROM alembic_version"))
version = result.scalar_one()
assert version is not None and len(version) > 0
engine.dispose() engine.dispose()
# #endregion test_legacy_database_stamp # #endregion test_legacy_database_upgrade
# #region _run_alembic_upgrade [C:1] [TYPE Function] # #region _run_alembic_upgrade [C:1] [TYPE Function]

View File

@@ -15,6 +15,10 @@
# @RELATION DEPENDS_ON -> [docker/backend.entrypoint.sh] # @RELATION DEPENDS_ON -> [docker/backend.entrypoint.sh]
# @DATA_CONTRACT Input: .env.enterprise-clean + ./certs/*.crt -> Output: запущенные контейнеры # @DATA_CONTRACT Input: .env.enterprise-clean + ./certs/*.crt -> Output: запущенные контейнеры
# @INVARIANT Внешний Postgres НЕ требует healthcheck внутри compose — ответственность оператора. # @INVARIANT Внешний Postgres НЕ требует healthcheck внутри compose — ответственность оператора.
# Backend entrypoint использует wait_for_db() (retry до 30 раз) для проверки доступности БД
# перед запуском миграций. Если БД недоступна — контейнер exit(1) после таймаута.
# @RATIONALE Нет depends_on для backend → внешний PostgreSQL: оператор гарантирует доступность.
# wait_for_db() в entrypoint.sh обеспечивает graceful retry вместо мгновенного crash.
# @INVARIANT Если CERTS_PATH пуст или не содержит .crt файлов, entrypoint не падает. # @INVARIANT Если CERTS_PATH пуст или не содержит .crt файлов, entrypoint не падает.
# @INVARIANT nginx слушает порт 80 всегда. Порт 443 включается только если есть server.crt + server.key. # @INVARIANT nginx слушает порт 80 всегда. Порт 443 включается только если есть server.crt + server.key.
# @RATIONALE Отказ от встроенного postgres-container: корпоративный стандарт — внешний PostgreSQL # @RATIONALE Отказ от встроенного postgres-container: корпоративный стандарт — внешний PostgreSQL

View File

@@ -2,14 +2,17 @@
set -euo pipefail set -euo pipefail
# #region docker.backend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,entrypoint,enterprise,certs] # #region docker.backend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,entrypoint,enterprise,certs]
# @BRIEF Container entrypoint — установка корп. сертификатов, bootstrap admin, # @BRIEF Container entrypoint — установка корп. сертификатов, wait-for-DB,
# затем запуск backend (exec "$@"). # Alembic миграции, bootstrap admin, затем запуск backend (exec "$@").
# @PRE Python runtime и backend source доступны в /app/backend. # @PRE Python runtime и backend source доступны в /app/backend.
# Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто). # Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто).
# DATABASE_URL установлен и указывает на доступный PostgreSQL.
# @POST Сертификаты установлены в системное хранилище и NSS базу Chromium. # @POST Сертификаты установлены в системное хранилище и NSS базу Chromium.
# БД подтверждена как доступная. Alembic миграции применены.
# Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@". # Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@".
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates. # @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates.
# Импортирует CA в NSS DB (~/.pki/nssdb) для Chromium. # Импортирует CA в NSS DB (~/.pki/nssdb) для Chromium.
# Выполняет Alembic upgrade head.
# @LAYER Infrastructure # @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [backend/src/scripts/create_admin.py] # @RELATION DEPENDS_ON -> [backend/src/scripts/create_admin.py]
# @RELATION DEPENDS_ON -> [backend/src/scripts/init_auth_db.py] # @RELATION DEPENDS_ON -> [backend/src/scripts/init_auth_db.py]
@@ -18,6 +21,9 @@ set -euo pipefail
# (create_admin.py идемпотентен). # (create_admin.py идемпотентен).
# @INVARIANT Повторный запуск install_llm_ca_certs не дублирует сертификаты в bundle. # @INVARIANT Повторный запуск install_llm_ca_certs не дублирует сертификаты в bundle.
# @INVARIANT Повторный запуск install_ca_to_nss не дублирует сертификаты в NSS DB. # @INVARIANT Повторный запуск install_ca_to_nss не дублирует сертификаты в NSS DB.
# @INVARIANT wait_for_db exit(1) если БД недоступна после DB_WAIT_ATTEMPTS попыток —
# контейнер не продолжит запуск с недоступной БД.
# @INVARIANT Alembic миграции выполняются ТОЛЬКО в entrypoint, НЕ в app.py lifespan.
# #endregion docker.backend.entrypoint # #endregion docker.backend.entrypoint
# ====================================================================== # ======================================================================
@@ -420,6 +426,73 @@ install_ca_to_nss() {
} }
# #endregion docker.backend.entrypoint.install_ca_to_nss # #endregion docker.backend.entrypoint.install_ca_to_nss
# ======================================================================
# wait_for_db — проверка доступности БД с retry
# ======================================================================
# #region docker.backend.entrypoint.wait_for_db [C:3] [TYPE Function]
# @BRIEF Проверяет доступность PostgreSQL с retry-логикой перед запуском миграций.
# @PRE DATABASE_URL установлен и указывает на валидный PostgreSQL.
# @POST Соединение с БД подтверждено (SELECT 1) или скрипт exit(1) после таймаута.
# @RATIONALE Без wait-for-db enterprise-деплой с внешним PostgreSQL падает
# мгновенно — entrypoint интерпретирует connection refused как "пустая БД".
# @REJECTED Использование pg_isready напрямую отвергнуто — клиент psql может
# отсутствовать в python:3.11-slim образе. SQLAlchemy-проверка универсальна.
wait_for_db() {
local max_attempts="${DB_WAIT_ATTEMPTS:-30}"
local delay="${DB_WAIT_DELAY:-2}"
local attempt=0
echo "[entrypoint] Waiting for database (max ${max_attempts} attempts, ${delay}s delay)..."
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# Capture stderr to detect permanent errors (KeyError, ArgumentError)
local _err_output
_err_output="$(python3 -c "
import os, sys
from sqlalchemy import create_engine, text
try:
url = os.environ['DATABASE_URL']
except KeyError:
print('FATAL: DATABASE_URL environment variable is not set', file=sys.stderr)
sys.exit(99)
try:
e = create_engine(url)
with e.connect() as c:
c.execute(text('SELECT 1'))
except Exception as exc:
# Permanent errors — no point retrying
err_str = str(exc).lower()
if 'database_url' in err_str or 'could not parse' in err_str or 'invalid' in err_str:
print(f'FATAL: {exc}', file=sys.stderr)
sys.exit(99)
sys.exit(1)
" 2>&1)"
local _rc=$?
if [ $_rc -eq 0 ]; then
echo "[entrypoint] ✅ Database is reachable"
return 0
fi
# Permanent error — exit immediately, don't waste retries
if [ $_rc -eq 99 ]; then
echo "[entrypoint] ❌ $_err_output" >&2
return 1
fi
echo "[entrypoint] Attempt ${attempt}/${max_attempts} — retrying in ${delay}s..."
sleep "$delay"
done
# P0: Mask credentials — never print full DATABASE_URL to logs
local _masked_url
_masked_url="$(echo "$DATABASE_URL" | sed -E 's|://([^:]+):([^@]+)@|://\1:***@|')"
echo "[entrypoint] ❌ Database not reachable after ${max_attempts} attempts" >&2
echo "[entrypoint] DATABASE_URL=${_masked_url}" >&2
return 1
}
# #endregion docker.backend.entrypoint.wait_for_db
# ====================================================================== # ======================================================================
# MAIN # MAIN
# ====================================================================== # ======================================================================
@@ -428,8 +501,10 @@ install_certificates
install_llm_ca_certs install_llm_ca_certs
install_ca_to_nss install_ca_to_nss
# Check DB state: empty, legacy (tables exist but no alembic), or managed # ── C1: Wait for database before any migration logic ──
# || prevents set -e from killing the script on non-zero exit from detection script wait_for_db
# ── C2: DB state detection (now safe — DB is confirmed reachable) ──
_db_state=0 _db_state=0
python3 -c " python3 -c "
import os import os
@@ -452,25 +527,11 @@ elif [ $_db_state -eq 1 ]; then
alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /' alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic initial migration applied" echo "[entrypoint] ✅ Alembic initial migration applied"
else else
echo "[entrypoint] Legacy database — adding critical migration columns..." echo "[entrypoint] Legacy database — running Alembic upgrade (not stamp)..."
# Add boot-critical columns that Alembic migrations would add # H2: Запускаем полноценный upgrade вместо stamp head — legacy-БД может
python3 -c " # отсутствовать таблицы, добавленные миграциями после написания entrypoint.
import os alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
from sqlalchemy import create_engine, inspect, text echo "[entrypoint] ✅ Alembic upgrade complete for legacy database"
e = create_engine(os.environ['DATABASE_URL'])
with e.begin() as conn:
inspector = inspect(conn)
roles_cols = {c['name'] for c in inspector.get_columns('roles')}
if 'is_admin' not in roles_cols:
conn.execute(text('ALTER TABLE roles ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE'))
conn.execute(text(\"UPDATE roles SET is_admin = TRUE WHERE name = 'Admin'\"))
print(' roles.is_admin column added')
else:
print(' roles.is_admin already exists')
"
echo "[entrypoint] Stamping Alembic head (tables already exist)..."
alembic stamp head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic stamped head, schema ready"
fi fi
bootstrap_admin bootstrap_admin

View File

@@ -234,19 +234,16 @@ stage1_enterprise_clean() {
docker build -f docker/frontend.Dockerfile -t "ss-tools-frontend:${RELEASE_TAG}" . docker build -f docker/frontend.Dockerfile -t "ss-tools-frontend:${RELEASE_TAG}" .
fi fi
# ── 1.3. START POSTGRESQL ──────────────────────────────── # ── 1.3. START POSTGRESQL (on default bridge first) ─────
log_info "Step 1.3: Starting new PostgreSQL container..." log_info "Step 1.3: Starting new PostgreSQL container..."
docker rm -f ss-tools-e2e-pg 2>/dev/null || true docker rm -f ss-tools-e2e-pg 2>/dev/null || true
# Create the network that docker compose will use (ss-tools-e2e-ec_default) # Remove any stale compose network so docker compose creates it fresh
# so PostgreSQL is on the same network and resolvable by container name. docker network rm ss-tools-e2e-ec_default 2>/dev/null || true
if ! docker network inspect ss-tools-e2e-ec_default >/dev/null 2>&1; then
docker network create ss-tools-e2e-ec_default
fi
# Start PG on default bridge initially — compose will create its own network
docker run -d \ docker run -d \
--name ss-tools-e2e-pg \ --name ss-tools-e2e-pg \
--network ss-tools-e2e-ec_default \
-e POSTGRES_DB=ss_tools \ -e POSTGRES_DB=ss_tools \
-e POSTGRES_USER=postgres \ -e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \ -e POSTGRES_PASSWORD=postgres \
@@ -267,7 +264,6 @@ stage1_enterprise_clean() {
sleep 1 sleep 1
done done
# Get PostgreSQL container IP on the enterprise network
EC_PG_HOST="ss-tools-e2e-pg" EC_PG_HOST="ss-tools-e2e-pg"
EC_PG_PORT="5432" EC_PG_PORT="5432"
@@ -308,11 +304,20 @@ EOF
export FRONTEND_TAG="${RELEASE_TAG}" export FRONTEND_TAG="${RELEASE_TAG}"
fi fi
# Start compose FIRST so it creates the network with proper compose labels.
# The backend will fail to connect to PG on first start (PG not on compose network
# yet), but Docker restart policy (unless-stopped) will retry.
docker compose -p ss-tools-e2e-ec \ docker compose -p ss-tools-e2e-ec \
-f "$EC_COMPOSE_FILE" \ -f "$EC_COMPOSE_FILE" \
--env-file "/tmp/.env.e2e-enterprise-clean" \ --env-file "/tmp/.env.e2e-enterprise-clean" \
up -d up -d
# Connect PG to the compose-created network so backend can resolve ss-tools-e2e-pg
docker network connect ss-tools-e2e-ec_default ss-tools-e2e-pg
# Restart backend so entrypoint reruns with PG accessible
docker compose -p ss-tools-e2e-ec restart backend
# ── 1.5. WAIT FOR BACKEND + FRONTEND ──────────────────── # ── 1.5. WAIT FOR BACKEND + FRONTEND ────────────────────
log_info "Step 1.5: Waiting for services..." log_info "Step 1.5: Waiting for services..."