From a2ede492b76a4a843812a91cdb50e68eb8851434 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 25 Aug 2026 15:21:42 +0300 Subject: [PATCH] fix(database): reset any legacy Alembic revision --- .env.enterprise-clean.example | 5 ++ backend/src/scripts/prepare_database.py | 27 ++++----- .../test_alembic_user_id_migration.py | 57 ++++++++++++++++++- backend/tests/test_release_templates.py | 1 + build.sh | 8 ++- docker-compose.e2e.yml | 1 - docker-compose.enterprise-clean.yml | 4 +- docker-compose.yml | 1 - 8 files changed, 83 insertions(+), 21 deletions(-) diff --git a/.env.enterprise-clean.example b/.env.enterprise-clean.example index 3c13a7c64..bb096ed41 100644 --- a/.env.enterprise-clean.example +++ b/.env.enterprise-clean.example @@ -18,6 +18,11 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=change-me POSTGRES_HOST_PORT=5432 +# Однократный универсальный reset старой Alembic-схемы перед baseline. +# Можно оставить true: после применения 0001_baseline последующие старты безопасно пропускают reset. +# Непустая БД без таблицы alembic_version никогда не удаляется автоматически. +RESET_DATABASE_SCHEMA=true + # ====================================================================== # Порты хоста # ====================================================================== diff --git a/backend/src/scripts/prepare_database.py b/backend/src/scripts/prepare_database.py index 329f7a041..f190ce637 100644 --- a/backend/src/scripts/prepare_database.py +++ b/backend/src/scripts/prepare_database.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +import sys from alembic.config import Config from sqlalchemy import create_engine, inspect, text @@ -13,7 +14,6 @@ from src.core.env_settings import database_url _MIGRATION_LOCK_ID = 481920260824 _BASELINE_REVISION = "0001_baseline" -_SUPPORTED_RESET_REVISIONS = {"e3f4a5b6c7d8", "e4f5a6b7c8d9"} def _enabled(value: str | None) -> bool: @@ -21,23 +21,16 @@ def _enabled(value: str | None) -> bool: def _maybe_reset(connection) -> None: - if not _enabled(os.getenv("RESET_DATABASE_SCHEMA")): + reset_enabled = _enabled(os.getenv("RESET_DATABASE_SCHEMA")) + sys.stderr.write( + f"[database] RESET_DATABASE_SCHEMA={'enabled' if reset_enabled else 'disabled'}\n" + ) + if not reset_enabled: return - expected_revision = os.getenv("RESET_DATABASE_FROM_REVISION", "").strip() - expected_database = os.getenv("RESET_DATABASE_NAME", "").strip() - if expected_revision not in _SUPPORTED_RESET_REVISIONS or not expected_database: - raise RuntimeError( - "RESET_DATABASE_SCHEMA requires a supported RESET_DATABASE_FROM_REVISION " - "and explicit RESET_DATABASE_NAME" - ) if connection.dialect.name != "postgresql": raise RuntimeError("RESET_DATABASE_SCHEMA is supported only for PostgreSQL") - database_name = connection.execute(text("SELECT current_database()")) .scalar_one() - if database_name != expected_database: - raise RuntimeError("RESET_DATABASE_NAME does not match the connected database") - tables = set(inspect(connection).get_table_names()) revision = None if "alembic_version" in tables: @@ -46,17 +39,21 @@ def _maybe_reset(connection) -> None: ).scalar() if revision == _BASELINE_REVISION: + sys.stderr.write("[database] Baseline already applied; one-shot reset skipped\n") connection.commit() return - if tables and revision != expected_revision: + if tables and revision is None: raise RuntimeError( - "Database is neither empty nor at RESET_DATABASE_FROM_REVISION; " + "Database schema is non-empty but has no Alembic revision; " "refusing destructive reset" ) connection.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) connection.execute(text("CREATE SCHEMA public")) connection.commit() + sys.stderr.write( + f"[database] Legacy Alembic revision {revision or ''} reset successfully\n" + ) def prepare_database() -> None: diff --git a/backend/tests/integration/test_alembic_user_id_migration.py b/backend/tests/integration/test_alembic_user_id_migration.py index 7deed3947..d329ad9cd 100644 --- a/backend/tests/integration/test_alembic_user_id_migration.py +++ b/backend/tests/integration/test_alembic_user_id_migration.py @@ -14,7 +14,7 @@ import os from pathlib import Path import pytest -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine, inspect, text # #region Test.Alembic.MigrationDatabaseUrl [C:2] [TYPE Function] @@ -59,6 +59,61 @@ def test_user_id_migration_adds_column(monkeypatch: pytest.MonkeyPatch, migratio # #endregion Test.Alembic.TestUserIdMigrationAddsColumn +def test_prepare_database_resets_any_legacy_revision( + monkeypatch: pytest.MonkeyPatch, + migration_database_url: str, +) -> None: + """An explicit reset replaces any old Alembic chain with the current baseline.""" + engine = create_engine(migration_database_url) + try: + with engine.begin() as connection: + connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(64) NOT NULL)")) + connection.execute( + text("INSERT INTO alembic_version (version_num) VALUES ('y4z5a6b7c8d9')") + ) + connection.execute(text("CREATE TABLE legacy_probe (id INTEGER PRIMARY KEY)")) + + monkeypatch.setenv("DATABASE_URL", migration_database_url) + monkeypatch.setenv("RESET_DATABASE_SCHEMA", "true") + monkeypatch.delenv("RESET_DATABASE_FROM_REVISION", raising=False) + monkeypatch.delenv("RESET_DATABASE_NAME", raising=False) + + from src.scripts.prepare_database import prepare_database + + prepare_database() + + tables = set(inspect(engine).get_table_names()) + assert "legacy_probe" not in tables + assert "task_records" in tables + with engine.connect() as connection: + revision = connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + assert revision == "0001_baseline" + finally: + engine.dispose() + + +def test_prepare_database_refuses_unversioned_nonempty_schema( + monkeypatch: pytest.MonkeyPatch, + migration_database_url: str, +) -> None: + """The universal reset must not erase an unrelated unversioned database.""" + engine = create_engine(migration_database_url) + try: + with engine.begin() as connection: + connection.execute(text("CREATE TABLE unrelated_data (id INTEGER PRIMARY KEY)")) + + monkeypatch.setenv("DATABASE_URL", migration_database_url) + monkeypatch.setenv("RESET_DATABASE_SCHEMA", "true") + + from src.scripts.prepare_database import prepare_database + + with pytest.raises(RuntimeError, match="non-empty but has no Alembic revision"): + prepare_database() + assert "unrelated_data" in inspect(engine).get_table_names() + finally: + engine.dispose() + + # #region _run_alembic_upgrade [C:1] [TYPE Function] def _run_alembic_upgrade(revision: str) -> None: """Run a named revision against the DATABASE_URL injected for this test.""" diff --git a/backend/tests/test_release_templates.py b/backend/tests/test_release_templates.py index 2da64996a..674acff9c 100644 --- a/backend/tests/test_release_templates.py +++ b/backend/tests/test_release_templates.py @@ -14,6 +14,7 @@ def test_generated_enterprise_backend_requires_service_jwt() -> None: backend_block = text.split(" backend:", 1)[1].split("\n\n frontend:", 1)[0] assert "SERVICE_JWT: \\${SERVICE_JWT:?Set SERVICE_JWT in .env}" in backend_block + assert "RESET_DATABASE_SCHEMA: \\${RESET_DATABASE_SCHEMA:-true}" in backend_block def test_generated_light_compose_requires_all_runtime_secrets() -> None: diff --git a/build.sh b/build.sh index d804b1cdc..b8aef52eb 100755 --- a/build.sh +++ b/build.sh @@ -471,6 +471,8 @@ services: condition: service_healthy environment: DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@db:5432/\${POSTGRES_DB:-ss_tools} + # Safe one-shot default: legacy revisions are reset; 0001_baseline is preserved. + RESET_DATABASE_SCHEMA: \${RESET_DATABASE_SCHEMA:-true} BACKEND_PORT: 8000 AUTH_SECRET_KEY: \${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env} ENCRYPTION_KEY: \${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env} @@ -640,6 +642,10 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=change-me POSTGRES_HOST_PORT=5432 +# One-shot reset of any legacy Alembic revision before applying 0001_baseline. +# Safe to leave true: once baseline is installed, subsequent starts skip reset. +RESET_DATABASE_SCHEMA=true + # ====================================================================== # Порты хоста # ====================================================================== @@ -765,7 +771,7 @@ bundle_release() { echo " xz -dc ${DIST_ROOT}/superset-tools-agent.${tag}.tar.xz | docker load" echo " xz -dc ${DIST_ROOT}/superset-tools-postgres.${tag}.tar.xz | docker load" echo " # Edit .env.enterprise-clean.example -> .env.enterprise-clean, then:" - echo " docker compose -f ${DIST_ROOT}/docker-compose.enterprise-clean.yml up -d" + echo " docker compose --env-file ${DIST_ROOT}/.env.enterprise-clean -f ${DIST_ROOT}/docker-compose.enterprise-clean.yml up -d" echo "" echo " NOTE: Bundle includes postgres container (db) — no external PostgreSQL needed." echo " Corporate certs: mount .crt files via CERTS_PATH=./certs (optional)." diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 6f898bb0e..b130d7c5d 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -38,7 +38,6 @@ services: environment: DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools RESET_DATABASE_SCHEMA: ${RESET_DATABASE_SCHEMA:-false} - RESET_DATABASE_FROM_REVISION: ${RESET_DATABASE_FROM_REVISION:-} BACKEND_PORT: 8000 AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.e2e} SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT in .env.e2e} diff --git a/docker-compose.enterprise-clean.yml b/docker-compose.enterprise-clean.yml index b8b3536e7..a6ac8c08d 100644 --- a/docker-compose.enterprise-clean.yml +++ b/docker-compose.enterprise-clean.yml @@ -63,8 +63,8 @@ services: environment: DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools} STORAGE_ROOT_PATH: /app/storage - RESET_DATABASE_SCHEMA: ${RESET_DATABASE_SCHEMA:-false} - RESET_DATABASE_FROM_REVISION: ${RESET_DATABASE_FROM_REVISION:-} + # Safe one-shot default: legacy revisions are reset; 0001_baseline is preserved. + RESET_DATABASE_SCHEMA: ${RESET_DATABASE_SCHEMA:-true} BACKEND_PORT: 8000 ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.enterprise-clean} diff --git a/docker-compose.yml b/docker-compose.yml index 452709744..5d9559139 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,6 @@ services: DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools STORAGE_ROOT_PATH: /app/storage RESET_DATABASE_SCHEMA: ${RESET_DATABASE_SCHEMA:-false} - RESET_DATABASE_FROM_REVISION: ${RESET_DATABASE_FROM_REVISION:-} BACKEND_PORT: 8000 ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} INITIAL_ADMIN_CREATE: ${INITIAL_ADMIN_CREATE:-false}