fix(database): reset any legacy Alembic revision

This commit is contained in:
2026-08-25 15:21:42 +03:00
parent e80c954b37
commit a2ede492b7
8 changed files with 83 additions and 21 deletions

View File

@@ -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
# ======================================================================
# Порты хоста
# ======================================================================

View File

@@ -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 '<empty>'} reset successfully\n"
)
def prepare_database() -> None:

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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)."

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}