Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c30cca78f3 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
108
build.sh
@@ -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 "$@" ;;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user