smoke alembic

This commit is contained in:
2026-07-23 18:53:45 +03:00
parent d8bbe4baa8
commit c30cca78f3
6 changed files with 191 additions and 5 deletions

View File

@@ -1,10 +1,15 @@
# #region Alembic.Migration.AddSessionActivityTable [C:2] [TYPE Migration] [SEMANTICS alembic,migration,session,activity,auth]
# @BRIEF Create session_activity table for idle/absolute session timeout enforcement.
# @PRE Previous migration (f7a8b9c0d1e2) has been applied; auth.users table exists.
# @POST session_activity table created with FK to users.id.
# @PRE Previous migration (f7a8b9c0d1e2) has been applied.
# @POST session_activity table is created when users exists; otherwise ORM create_all()
# creates it after the fresh Alembic upgrade.
# @SIDE_EFFECT DDL execution — creates table, index, foreign key constraint.
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
# @RELATION DEPENDS_ON -> [Models.Auth.User]
# @RATIONALE users is ORM-owned and is created by Base.metadata.create_all() after
# Alembic during a fresh install, so the FK migration must be safely skipped first.
# @REJECTED Unconditionally creating the FK table was rejected — fresh installs fail
# before application startup because users does not yet exist.
"""add session_activity table
Revision ID: 8e9f0a1b2c3d
@@ -42,7 +47,7 @@ def _table_exists(table_name: str) -> bool:
# @SIDE_EFFECT Executes CREATE TABLE DDL.
def upgrade() -> None:
"""Create session_activity table for idle/absolute timeout enforcement."""
if _table_exists("session_activity"):
if _table_exists("session_activity") or not _table_exists("users"):
return
op.create_table(
"session_activity",

View File

@@ -26,6 +26,8 @@ depends_on: str | Sequence[str] | None = None
def _add_col_if_missing(table: str, column: sa.Column) -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(table):
return
existing = {c["name"] for c in inspector.get_columns(table)}
if column.name in existing:
return
@@ -38,6 +40,8 @@ def _add_col_if_missing(table: str, column: sa.Column) -> None:
# @BRIEF Add nullable performance knobs to translation_jobs and llm_providers.
# @RATIONALE NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps);
# capabilities are stored in DB, not inferred from brand/host at runtime.
# @RATIONALE llm_providers is an ORM-owned table created by Base.metadata.create_all()
# after Alembic on a fresh install; absent tables must therefore be skipped here.
# @REJECTED Non-nullable columns with server defaults — would silently flip legacy jobs to new behaviour.
def upgrade() -> None:
# ── translation_jobs (job policy / performance) ────────────────────────
@@ -164,6 +168,8 @@ def downgrade() -> None:
if name in job_cols:
op.drop_column("translation_jobs", name)
if not inspector.has_table("llm_providers"):
return
prov_cols = {c["name"] for c in inspector.get_columns("llm_providers")}
for name in (
"max_llm_concurrency",

View File

@@ -13,10 +13,13 @@
# @REJECTED Legacy stamp + raw SQL rejected — entrypoint now runs `alembic upgrade head`
# for legacy databases, ensuring all missing tables/columns are created.
import importlib.util
import os
from pathlib import Path
import pytest
import sys
from unittest.mock import Mock
import pytest
# Ensure backend/src is importable for model metadata
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
@@ -100,6 +103,66 @@ def test_legacy_database_upgrade() -> None:
# #endregion Test.AlembicMigrations.TestLegacyDatabaseUpgrade
# #region Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
# @BRIEF Verify the performance-knobs migration skips ORM-owned tables absent during a fresh upgrade.
# @POST No reflection or DDL operation runs when llm_providers does not exist yet.
def test_performance_knobs_skips_absent_optional_table(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fresh Alembic upgrades must not require llm_providers before create_all()."""
migration_path = (
Path(__file__).parent.parent
/ "alembic"
/ "versions"
/ "f7a8b9c0d1e2_add_translate_performance_knobs.py"
)
spec = importlib.util.spec_from_file_location("performance_knobs_migration", migration_path)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
inspector = Mock()
inspector.has_table.return_value = False
bind = object()
add_column = Mock()
monkeypatch.setattr(migration.op, "get_bind", lambda: bind)
monkeypatch.setattr(migration.sa, "inspect", lambda received: inspector)
monkeypatch.setattr(migration.op, "add_column", add_column)
migration._add_col_if_missing("llm_providers", migration.sa.Column("throughput_class", migration.sa.String()))
inspector.get_columns.assert_not_called()
add_column.assert_not_called()
# #endregion Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable
# #region Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
# @BRIEF Verify the session-activity migration skips its FK table before ORM creates users.
# @POST No CREATE TABLE operation runs when users is absent during a fresh Alembic upgrade.
def test_session_activity_skips_absent_users(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fresh Alembic upgrades must not create FK tables before their ORM parent exists."""
migration_path = (
Path(__file__).parent.parent
/ "alembic"
/ "versions"
/ "8e9f0a1b2c3d_add_session_activity_table.py"
)
spec = importlib.util.spec_from_file_location("session_activity_migration", migration_path)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
inspector = Mock()
inspector.get_table_names.return_value = []
create_table = Mock()
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
monkeypatch.setattr(migration, "inspect", lambda bind: inspector)
monkeypatch.setattr(migration.op, "create_table", create_table)
migration.upgrade()
create_table.assert_not_called()
# #endregion Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers
# #region Test.AlembicMigrations.RunAlembicUpgrade [C:1] [TYPE Function]
def _run_alembic_upgrade(revision: str = "head") -> None:
"""Run a named Alembic upgrade programmatically against DATABASE_URL."""

108
build.sh
View File

@@ -22,6 +22,10 @@
# bundle:frontend <tag> Build + export frontend .tar.xz only
# bundle:agent <tag> Build + export agent .tar.xz only
#
# Commands (release smoke):
# smoke:bundle <backend-image> <postgres-image>
# Validate clean PostgreSQL migration + backend boot/restart.
#
# Commands (full bundles — backend + frontend + agent + postgres):
# bundle <tag> Slim enterprise bundle (no embeddings agent). Default.
# bundle:embeddings <tag> Enterprise bundle WITH semantic embedding routing (larger agent)
@@ -215,6 +219,93 @@ export_image() {
echo "[bundle] ✅ ${archive} saved"
}
# ======================================================================
# RELEASE SMOKE TESTS
# ======================================================================
run_bundle_smoke() {
local backend_image="$1"
local postgres_image="$2"
local timeout_seconds="${BUNDLE_SMOKE_TIMEOUT_SECONDS:-180}"
local suffix="$(date +%s)-${RANDOM}"
local network="ss-tools-release-smoke-${suffix}"
local db_container="ss-tools-release-smoke-db-${suffix}"
local backend_container="ss-tools-release-smoke-backend-${suffix}"
local database_url="postgresql+psycopg2://postgres:postgres@${db_container}:5432/release_smoke"
(
set -euo pipefail
cleanup_bundle_smoke() {
docker rm -f "${backend_container}" "${db_container}" >/dev/null 2>&1 || true
docker network rm "${network}" >/dev/null 2>&1 || true
}
trap cleanup_bundle_smoke EXIT
wait_for_postgres() {
local elapsed=0
while (( elapsed < timeout_seconds )); do
if docker exec "${db_container}" pg_isready -U postgres -d release_smoke >/dev/null 2>&1; then
return 0
fi
sleep 2
((elapsed += 2))
done
echo "[smoke] ❌ PostgreSQL did not become ready within ${timeout_seconds}s" >&2
docker logs "${db_container}" >&2 || true
return 1
}
wait_for_backend_health() {
local phase="$1"
local elapsed=0
while (( elapsed < timeout_seconds )); do
if docker exec "${backend_container}" curl -fsS http://127.0.0.1:8000/ >/dev/null 2>&1; then
echo "[smoke] ✅ Backend health check passed (${phase})"
return 0
fi
if [[ "$(docker inspect --format '{{.State.Running}}' "${backend_container}" 2>/dev/null || true)" != "true" ]]; then
echo "[smoke] ❌ Backend exited during ${phase}" >&2
docker logs "${backend_container}" >&2 || true
return 1
fi
sleep 2
((elapsed += 2))
done
echo "[smoke] ❌ Backend health check timed out during ${phase}" >&2
docker logs "${backend_container}" >&2 || true
return 1
}
echo "[smoke] Starting clean PostgreSQL release gate..."
docker network create "${network}" >/dev/null
docker run -d --name "${db_container}" --network "${network}" \
--security-opt seccomp=unconfined \
-e POSTGRES_DB=release_smoke \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
"${postgres_image}" >/dev/null
wait_for_postgres
echo "[smoke] Verifying fresh migration, ORM schema initialization, and backend health..."
docker run -d --name "${backend_container}" --network "${network}" \
-e DATABASE_URL="${database_url}" \
-e TASKS_DATABASE_URL="${database_url}" \
-e AUTH_DATABASE_URL="${database_url}" \
-e AUTH_SECRET_KEY=release-smoke-auth-secret \
-e ENCRYPTION_KEY=change-me-generate-a-fernet-key= \
-e SERVICE_JWT=release-smoke-service-token \
-e INITIAL_ADMIN_CREATE=false \
"${backend_image}" >/dev/null
wait_for_backend_health "initial start"
echo "[smoke] Verifying idempotent migration and restart..."
docker restart "${backend_container}" >/dev/null
wait_for_backend_health "restart"
echo "[smoke] ✅ Clean PostgreSQL release gate passed"
)
}
# ======================================================================
# COMPOSE COMMANDS
# ======================================================================
@@ -344,6 +435,8 @@ services:
image: ${postgres_tag}
pull_policy: never
restart: unless-stopped
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: \${POSTGRES_DB:-ss_tools}
POSTGRES_USER: \${POSTGRES_USER:-postgres}
@@ -630,6 +723,9 @@ bundle_release() {
docker pull "${postgres_source}"
docker tag "${postgres_source}" "${postgres_tag}"
# Release gate: fresh PostgreSQL migration + backend boot + restart must pass.
run_bundle_smoke "${backend_tag}" "${postgres_tag}"
# Export .tar.xz archives
echo "[bundle] Exporting .tar.xz archives..."
export_image "${backend_tag}" "${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz"
@@ -813,6 +909,9 @@ bundle_embeddings() {
docker pull "${postgres_source}"
docker tag "${postgres_source}" "${postgres_tag}"
# Release gate: fresh PostgreSQL migration + backend boot + restart must pass.
run_bundle_smoke "${backend_tag}" "${postgres_tag}"
# Export .tar.xz archives
echo "[bundle:embeddings] Exporting .tar.xz archives..."
export_image "${backend_tag}" "${DIST_ROOT}/superset-tools-backend.${tag}${suffix}.tar.xz"
@@ -879,10 +978,15 @@ Commands for single-image bundle (build + .tar.xz export):
bundle:frontend <tag> Build + export frontend .tar.xz only
bundle:agent <tag> Build + export agent .tar.xz only
Release verification:
smoke:bundle <backend-image> <postgres-image>
Run clean PostgreSQL migration + backend boot/restart gate.
Commands for full bundles (backend + frontend + agent + postgres):
bundle <tag> Default enterprise bundle (4 .tar.xz archives).
Includes: backend + frontend + agent (slim) + postgres (16-alpine).
PostgreSQL работает в докер-контейнере — внешний БД не требуется.
Before export: clean PostgreSQL migration + backend boot/restart smoke gate.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle v1.0.0
@@ -890,6 +994,7 @@ Commands for full bundles (backend + frontend + agent + postgres):
Enterprise bundle WITH semantic embedding routing.
Agent built WITH sentence-transformers+torch (larger image).
Includes: backend + frontend + agent (embeddings) + postgres.
Before export: clean PostgreSQL migration + backend boot/restart smoke gate.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle:embeddings v1.0.0
@@ -930,7 +1035,7 @@ main() {
shift 2>/dev/null || true
case "$CMD" in
up|down|restart|logs|status|help|-h|--help|bundle|bundle:embeddings|bundle:light|\
up|down|restart|logs|status|help|-h|--help|bundle|bundle:embeddings|bundle:light|smoke:bundle|\
build:backend|build:frontend|build:agent|\
bundle:backend|bundle:frontend|bundle:agent)
# Valid commands — proceed
@@ -951,6 +1056,7 @@ main() {
bundle) bundle_release "$@" ;;
bundle:embeddings) bundle_embeddings "$@" ;;
bundle:light) bundle_light "$@" ;;
smoke:bundle) run_bundle_smoke "$@" ;;
build:backend) build_backend "$@" ;;
build:frontend) build_frontend "$@" ;;
build:agent) build_agent "$@" ;;

View File

@@ -35,6 +35,9 @@ services:
db:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
# Compatibility workaround for legacy Docker/libseccomp hosts running PostgreSQL 16.
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: ${POSTGRES_DB:-ss_tools}
POSTGRES_USER: ${POSTGRES_USER:-postgres}

View File

@@ -2,6 +2,9 @@ services:
db:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
# Compatibility workaround for legacy Docker/libseccomp hosts running PostgreSQL 16.
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: ss_tools
POSTGRES_USER: postgres