fix(database): reset any legacy Alembic revision
This commit is contained in:
@@ -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
|
||||
|
||||
# ======================================================================
|
||||
# Порты хоста
|
||||
# ======================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
8
build.sh
8
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)."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user