Files
ss-tools/build.sh
busya 5758ae4a83 fix(database): object-level reset and optional DB_SCHEMA isolation
Safe one-shot reset, fail-fast on unknown revisions, object-level drops for non-owner corporate PG, rollback before advisory unlock, configurable DB_SCHEMA via search_path, e2e migration matrix tests.
2026-09-11 16:17:11 +03:00

936 lines
34 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# #region Tooling.Build [C:2] [TYPE Module]
# @PURPOSE: Unified build script — local docker compose + individual image builds + release bundles
# @COMPLEXITY: 2
#
# Usage: ./build.sh <command> [options]
#
# Commands (local docker):
# up [profile] Build and start local docker compose (default: current)
# down [profile] Stop services
# restart [profile] Rebuild and restart services
# logs [profile] Tail logs
# status Show running containers for all profiles
#
# Commands (individual image build — no export):
# build:backend <tag> Build backend image only (superset-tools-backend:<tag>)
# build:frontend <tag> Build frontend image only (superset-tools-frontend:<tag>)
#
# Commands (single-image bundle — build + .tar.xz export):
# bundle:backend <tag> Build + export backend .tar.xz only
# bundle:frontend <tag> Build + export frontend .tar.xz only
#
# Commands (release smoke):
# smoke:bundle <backend-image> <postgres-image>
# Validate clean PostgreSQL migration + backend boot/restart.
#
# Commands (full bundles — backend + frontend + postgres):
# bundle <tag> Enterprise bundle (backend + frontend + postgres). Default.
# bundle:light <tag> Lightweight all-in-one bundle (<200 MB, .tar.xz, no Playwright)
#
# help Show this help
#
# Profiles: current (default), master, enterprise-clean
# #endregion Tooling.Build
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
export DOCKER_BUILDKIT=1
BACKEND_ENV_FILE="$SCRIPT_DIR/backend/.env"
DIST_ROOT="$SCRIPT_DIR/dist/docker"
# ---- Docker Compose detection ----
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
echo "Error: docker compose is not available."
exit 1
fi
# ======================================================================
# HELPERS
# ======================================================================
# shellcheck disable=SC2206
setup_compose_profile() {
local profile="$1"
case "$profile" in
master)
PROFILE_ENV_FILE="$SCRIPT_DIR/.env.master"
PROJECT_NAME="superset-tools-master"
COMPOSE_FILE="docker-compose.yml"
;;
current|"")
PROFILE_ENV_FILE="$SCRIPT_DIR/.env.current"
PROJECT_NAME="superset-tools-current"
COMPOSE_FILE="docker-compose.yml"
;;
enterprise-clean)
PROFILE_ENV_FILE="$SCRIPT_DIR/.env"
PROJECT_NAME="superset-tools-enterprise"
COMPOSE_FILE="docker-compose.enterprise-clean.yml"
;;
*)
echo "Error: unknown profile '$profile'. Use: current, master, enterprise-clean."
exit 1
;;
esac
COMPOSE_ARGS=(-p "$PROJECT_NAME" -f "$COMPOSE_FILE")
if [[ -f "$PROFILE_ENV_FILE" ]]; then
COMPOSE_ARGS+=(--env-file "$PROFILE_ENV_FILE")
fi
}
ensure_backend_encryption_key() {
if command -v python3 >/dev/null 2>&1; then
python3 - "$BACKEND_ENV_FILE" <<'PY'
import base64
import os
import sys
from pathlib import Path
from typing import List, Optional
def is_valid_fernet_key(raw_value: str) -> bool:
value = raw_value.strip()
if not value:
return False
try:
decoded = base64.urlsafe_b64decode(value.encode())
except Exception:
return False
return len(decoded) == 32
def generate_fernet_key() -> str:
return base64.urlsafe_b64encode(os.urandom(32)).decode()
env_path = Path(sys.argv[1])
env_path.parent.mkdir(parents=True, exist_ok=True)
existing_lines: List[str] = []
existing_key: Optional[str] = None
if env_path.exists():
existing_lines = env_path.read_text(encoding="utf-8").splitlines()
for line in existing_lines:
if line.startswith("ENCRYPTION_KEY="):
candidate = line.partition("=")[2].strip()
if is_valid_fernet_key(candidate):
existing_key = candidate
break
if existing_key is None:
generated_key = generate_fernet_key()
filtered_lines = [line for line in existing_lines if not line.startswith("ENCRYPTION_KEY=")]
filtered_lines.append(f"ENCRYPTION_KEY={generated_key}")
env_path.write_text("\n".join(filtered_lines) + "\n", encoding="utf-8")
print(f"[build] ENCRYPTION_KEY ensured in {env_path}")
else:
print(f"[build] Existing ENCRYPTION_KEY reused from {env_path}")
PY
return
fi
echo "Error: python3 is required to generate backend/.env with ENCRYPTION_KEY."
exit 1
}
get_image_id() {
docker image inspect --format '{{.Id}}' "$1"
}
get_repo_digest() {
local digest
digest="$(docker image inspect --format '{{join .RepoDigests ","}}' "$1" 2>/dev/null || true)"
if [[ -n "${digest}" && "${digest}" != "<no value>" ]]; then
printf '%s' "${digest}"
else
printf '%s' "unavailable"
fi
}
# Pull an image only if it is not already present locally.
ensure_image_present() {
local source="$1"
if docker image inspect "$source" >/dev/null 2>&1; then
echo "[bundle] Image ${source} already present, skipping pull"
return 0
fi
docker pull "$source"
}
resolve_app_version() {
local tag="$1"
local git_commit
git_commit="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
echo "${tag}+${git_commit}"
}
# ======================================================================
# SINGLE-IMAGE BUILD FUNCTIONS
# ======================================================================
build_backend() {
local tag="$1"
local image="superset-tools-backend:${tag}"
echo "[build] Building backend image ${image}..."
docker build -f docker/backend.Dockerfile -t "${image}" .
echo "[build] ✅ Backend image ${image} built"
}
build_frontend() {
local tag="$1"
local image="superset-tools-frontend:${tag}"
local app_version
app_version="$(resolve_app_version "${tag}")"
echo "[build] Building frontend image ${image} (APP_VERSION=${app_version})..."
docker build --build-arg "APP_VERSION=${app_version}" -f docker/frontend.Dockerfile -t "${image}" .
echo "[build] ✅ Frontend image ${image} built"
}
# ======================================================================
# SINGLE-IMAGE EXPORT
# ======================================================================
export_image() {
local image="$1"
local archive="$2"
echo "[bundle] Compressing ${image} -> ${archive}..."
mkdir -p "$(dirname "${archive}")"
docker save "${image}" | xz -T0 -3 > "${archive}"
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 AUTH_SECRET_KEY=release-smoke-auth-secret \
-e ENCRYPTION_KEY="$(python3 -c 'import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')" \
-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
# ======================================================================
compose_up() {
local profile="${1:-current}"
setup_compose_profile "$profile"
ensure_backend_encryption_key
echo "[build] Profile: $profile (project: $PROJECT_NAME)"
if [[ -f "$PROFILE_ENV_FILE" ]]; then
echo "[build] Env file: $PROFILE_ENV_FILE"
fi
echo "[1/2] Building project images..."
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" build
echo "[2/2] Starting Docker services..."
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" up -d
echo "Done. Services are running."
echo " ${COMPOSE_CMD[*]} ${COMPOSE_ARGS[*]} ps"
echo " ${COMPOSE_CMD[*]} ${COMPOSE_ARGS[*]} logs -f"
}
compose_down() {
local profile="${1:-current}"
setup_compose_profile "$profile"
echo "[build] Stopping $profile (project: $PROJECT_NAME)..."
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" down
echo "[build] Done."
}
compose_restart() {
local profile="${1:-current}"
setup_compose_profile "$profile"
ensure_backend_encryption_key
echo "[build] Restarting $profile (project: $PROJECT_NAME)..."
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" build
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" up -d --force-recreate
echo "[build] Done."
}
compose_logs() {
local profile="${1:-current}"
setup_compose_profile "$profile"
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" logs -f
}
compose_status() {
echo "=== superset-tools docker status ==="
for profile in current master enterprise-clean; do
setup_compose_profile "$profile"
echo "--- $profile (project: $PROJECT_NAME) ---"
"${COMPOSE_CMD[@]}" "${COMPOSE_ARGS[@]}" ps 2>/dev/null || echo "(not running)"
done
}
# ======================================================================
# BUNDLE: full release (backend + frontend .tar.xz's + bundled postgres)
# ======================================================================
# ======================================================================
# SINGLE-IMAGE BUNDLE FUNCTIONS
# ======================================================================
bundle_single_backend() {
if [[ $# -lt 1 ]]; then
echo "Error: tag is required."
echo "Usage: ./build.sh bundle:backend <tag>"
exit 1
fi
local tag="$1"
local image="superset-tools-backend:${tag}"
mkdir -p "$DIST_ROOT"
build_backend "${tag}"
export_image "${image}" "${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz"
(cd "${DIST_ROOT}" && sha256sum "superset-tools-backend.${tag}.tar.xz" > "sha256sums-backend.${tag}.txt")
echo "[bundle:backend] ✅ Backend bundle ${tag} created"
echo " xz -dc ${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz | docker load"
}
bundle_single_frontend() {
if [[ $# -lt 1 ]]; then
echo "Error: tag is required."
echo "Usage: ./build.sh bundle:frontend <tag>"
exit 1
fi
local tag="$1"
local image="superset-tools-frontend:${tag}"
mkdir -p "$DIST_ROOT"
build_frontend "${tag}"
export_image "${image}" "${DIST_ROOT}/superset-tools-frontend.${tag}.tar.xz"
(cd "${DIST_ROOT}" && sha256sum "superset-tools-frontend.${tag}.tar.xz" > "sha256sums-frontend.${tag}.txt")
echo "[bundle:frontend] ✅ Frontend bundle ${tag} created"
echo " xz -dc ${DIST_ROOT}/superset-tools-frontend.${tag}.tar.xz | docker load"
}
# ======================================================================
# FULL BUNDLE: backend + frontend + postgres
# ======================================================================
generate_deploy_compose() {
local tag="$1" backend_tag="$2" frontend_tag="$3" postgres_tag="$4"
cat > "${DIST_ROOT}/docker-compose.enterprise-clean.yml" <<DEPLOY
services:
db:
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}
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
ports:
- "\${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-postgres} -d \${POSTGRES_DB:-ss_tools}"]
interval: 10s
timeout: 5s
retries: 10
backend:
image: ${backend_tag}
pull_policy: never
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@db:5432/\${POSTGRES_DB:-ss_tools}
DB_SCHEMA: \${DB_SCHEMA:-public}
# One-shot destructive reset, only for databases NOT on the current chain
# (legacy/orphaned Alembic revisions). Chain-managed schemas are never wiped.
# The reset drops schema objects, so it needs object ownership, not schema ownership.
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}
SERVICE_JWT: \${SERVICE_JWT:?Set SERVICE_JWT in .env}
MCP_ALLOWED_HOSTS: \${MCP_ALLOWED_HOSTS:-127.0.0.1:*,localhost:*,[::1]:*}
MCP_ALLOWED_ORIGINS: \${MCP_ALLOWED_ORIGINS:-http://127.0.0.1:*,http://localhost:*,http://[::1]:*}
FORWARDED_ALLOW_IPS: \${FORWARDED_ALLOW_IPS:-*}
ENABLE_BELIEF_STATE_LOGGING: \${ENABLE_BELIEF_STATE_LOGGING:-true}
TASK_LOG_LEVEL: \${TASK_LOG_LEVEL:-INFO}
INITIAL_ADMIN_CREATE: \${INITIAL_ADMIN_CREATE:-false}
INITIAL_ADMIN_USERNAME: \${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: \${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_EMAIL: \${INITIAL_ADMIN_EMAIL:-}
OPENAI_API_KEY: \${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY:-}
FEATURES__DATASET_REVIEW: \${FEATURES__DATASET_REVIEW:-true}
FEATURES__HEALTH_MONITOR: \${FEATURES__HEALTH_MONITOR:-true}
CERTS_PATH: /opt/certs
ports:
- "127.0.0.1:\${BACKEND_HOST_PORT:-8001}:8000"
volumes:
- ./storage:/app/storage
- \${CERTS_PATH:-./certs}:/opt/certs:ro
frontend:
image: ${frontend_tag}
pull_policy: never
restart: unless-stopped
depends_on:
- backend
environment:
SSL_KEY_PASSPHRASE: \${SSL_KEY_PASSPHRASE:-}
ports:
- "\${FRONTEND_HOST_PORT:-8000}:80"
- "\${FRONTEND_SSL_PORT:-443}:443"
volumes:
- \${CERTS_PATH:-./certs}:/opt/certs:ro
volumes:
postgres_data:
DEPLOY
}
generate_manifest() {
local tag="$1" backend_tag="$2" frontend_tag="$3" postgres_tag="$4"
local suffix="${5:-}"
local backend_id frontend_id postgres_id
backend_id="$(get_image_id "${backend_tag}")"
frontend_id="$(get_image_id "${frontend_tag}")"
postgres_id="$(get_image_id "${postgres_tag}")"
cat > "${DIST_ROOT}/manifest.${tag}${suffix}.txt" <<EOF
release_tag=${tag}
backend_image=${backend_tag}
backend_image_id=${backend_id}
backend_repo_digest=$(get_repo_digest "${backend_tag}")
backend_archive=superset-tools-backend.${tag}${suffix}.tar.xz
frontend_image=${frontend_tag}
frontend_image_id=${frontend_id}
frontend_repo_digest=$(get_repo_digest "${frontend_tag}")
frontend_archive=superset-tools-frontend.${tag}${suffix}.tar.xz
postgres_image=${postgres_tag}
postgres_image_id=${postgres_id}
postgres_repo_digest=$(get_repo_digest "${postgres_tag}")
postgres_archive=superset-tools-postgres.${tag}${suffix}.tar.xz
compose_file=docker-compose.enterprise-clean.yml
env_template=.env.example
env_bootstrap_fields=INITIAL_ADMIN_CREATE,INITIAL_ADMIN_USERNAME,INITIAL_ADMIN_PASSWORD,INITIAL_ADMIN_EMAIL
checksums_file=sha256sums.${tag}${suffix}.txt
generated_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
EOF
cat > "${DIST_ROOT}/manifest.${tag}${suffix}.json" <<EOF
{
"release_tag": "${tag}",
"generated_at_utc": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"images": [
{
"role": "postgres",
"image": "${postgres_tag}",
"image_id": "${postgres_id}",
"repo_digest": "$(get_repo_digest "${postgres_tag}")",
"archive": "superset-tools-postgres.${tag}${suffix}.tar.xz"
},
{
"role": "backend",
"image": "${backend_tag}",
"image_id": "${backend_id}",
"repo_digest": "$(get_repo_digest "${backend_tag}")",
"archive": "superset-tools-backend.${tag}${suffix}.tar.xz"
},
{
"role": "frontend",
"image": "${frontend_tag}",
"image_id": "${frontend_id}",
"repo_digest": "$(get_repo_digest "${frontend_tag}")",
"archive": "superset-tools-frontend.${tag}${suffix}.tar.xz"
}
],
"compose_file": "docker-compose.enterprise-clean.yml",
"env_template": ".env.example",
"env_bootstrap_fields": [
"INITIAL_ADMIN_CREATE",
"INITIAL_ADMIN_USERNAME",
"INITIAL_ADMIN_PASSWORD",
"INITIAL_ADMIN_EMAIL"
],
"checksums_file": "sha256sums.${tag}${suffix}.txt"
}
EOF
}
# ======================================================================
# GENERATE BUNDLE ENV TEMPLATE (with bundled postgres defaults)
# ======================================================================
generate_bundle_env() {
local tag="$1"
cat > "${DIST_ROOT}/.env.example" << 'ENV'
# #region env.enterprise-clean.bundle [C:2] [TYPE Module] [SEMANTICS env,docker,bundle]
# @BRIEF Переменные окружения для docker-compose.enterprise-clean.yml (bundled postgres).
# Все образы предварительно загружены через xz -dc ... | docker load.
# PostgreSQL запускается в контейнере (сервис db) — внешний БД не требуется.
# @LAYER Infrastructure
# #endregion env.enterprise-clean.bundle
# ======================================================================
# PostgreSQL (контейнер) — настройки БД
# ======================================================================
POSTGRES_DB=ss_tools
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me
POSTGRES_HOST_PORT=5432
# Схема, в которой живут все объекты приложения (по умолчанию public).
# Для внешней корпоративной БД: DB_SCHEMA=ss_tools (схему один раз создаёт DBA).
DB_SCHEMA=public
# One-shot destructive reset, only for databases NOT on the current chain
# (legacy/orphaned Alembic revisions). true never wipes a chain-managed schema;
# false/unset fails fast on an unknown revision instead of dropping anything.
# A reset that actually drops needs ownership of the schema objects, not the schema itself.
RESET_DATABASE_SCHEMA=true
# ======================================================================
# Порты хоста
# ======================================================================
BACKEND_HOST_PORT=8001
FRONTEND_HOST_PORT=8000
FRONTEND_SSL_PORT=443
# ======================================================================
# Безопасность (ОБЯЗАТЕЛЬНО)
# ======================================================================
# JWT-ключ подписи токенов.
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=change-me-to-a-random-secret-32-chars-min
# Fernet-ключ шифрования паролей подключений и API-ключей.
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=change-me-generate-a-fernet-key
# Сервисный токен для внутренних сервисных принципалов (например, MCP).
# Сгенерировать: python3 -c "import secrets; print('svc-' + secrets.token_urlsafe(24))"
SERVICE_JWT=change-me-service-secret
# ======================================================================
# Сертификаты (корпоративные, опционально)
# ======================================================================
CERTS_PATH=./certs
SSL_KEY_PASSPHRASE=
# ======================================================================
# Логирование
# ======================================================================
ENABLE_BELIEF_STATE_LOGGING=true
TASK_LOG_LEVEL=INFO
# ======================================================================
# Admin bootstrap (первый запуск)
# ======================================================================
INITIAL_ADMIN_CREATE=false
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=change-me
INITIAL_ADMIN_EMAIL=
# ======================================================================
# CORS / Безопасность деплоя
# ======================================================================
ALLOWED_ORIGINS=http://localhost:8000
FORCE_HTTPS=false
APP_TIMEZONE=Europe/Moscow
# ======================================================================
# Features
# ======================================================================
FEATURES__DATASET_REVIEW=true
FEATURES__HEALTH_MONITOR=true
ENV
echo "[bundle] ✅ Generated bundle env template: ${DIST_ROOT}/.env.example"
}
bundle_release() {
if [[ $# -lt 1 ]]; then
echo "Error: tag is required."
echo "Usage: ./build.sh bundle <tag> (e.g. v1.0.0)"
exit 1
fi
local tag="$1"
local backend_tag="superset-tools-backend:${tag}"
local frontend_tag="superset-tools-frontend:${tag}"
local postgres_tag="superset-tools-postgres:${tag}"
local postgres_source="${POSTGRES_IMAGE:-postgres:16-alpine}"
mkdir -p "$DIST_ROOT"
# Build application images
build_backend "${tag}"
build_frontend "${tag}"
# Pull and tag postgres image
echo "[bundle] Ensuring postgres image ${postgres_source}..."
ensure_image_present "${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"
export_image "${frontend_tag}" "${DIST_ROOT}/superset-tools-frontend.${tag}.tar.xz"
export_image "${postgres_tag}" "${DIST_ROOT}/superset-tools-postgres.${tag}.tar.xz"
# Checksums
echo "[bundle] Calculating checksums..."
(cd "${DIST_ROOT}" && sha256sum \
"superset-tools-backend.${tag}.tar.xz" \
"superset-tools-frontend.${tag}.tar.xz" \
"superset-tools-postgres.${tag}.tar.xz" > "sha256sums.${tag}.txt")
# Generate deploy compose + manifest
generate_deploy_compose "${tag}" "${backend_tag}" "${frontend_tag}" "${postgres_tag}"
generate_manifest "${tag}" "${backend_tag}" "${frontend_tag}" "${postgres_tag}"
# Generate env template for the bundle
generate_bundle_env "${tag}"
echo "[bundle] ✅ Bundle created in ${DIST_ROOT}"
echo " # Load images (decompress stream):"
echo " xz -dc ${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz | docker load"
echo " xz -dc ${DIST_ROOT}/superset-tools-frontend.${tag}.tar.xz | docker load"
echo " xz -dc ${DIST_ROOT}/superset-tools-postgres.${tag}.tar.xz | docker load"
echo " # In the bundle directory, create and edit the standard Compose .env:"
echo " cd ${DIST_ROOT} && cp .env.example .env"
echo " docker compose -f 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)."
}
# ======================================================================
# BUNDLE: lightweight all-in-one (single image, no Playwright, <200 MB)
# ======================================================================
bundle_light() {
if [[ $# -lt 1 ]]; then
echo "Error: tag is required."
echo "Usage: ./build.sh bundle:light <tag> (e.g. v1.0.0)"
exit 1
fi
local tag="$1"
local image_tag="superset-tools:${tag}"
# Git commit hash for APP_VERSION (e.g. "0.5.2+58d06fb2")
local git_commit
git_commit="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
local app_version="${tag}+${git_commit}"
mkdir -p "$DIST_ROOT"
echo "[bundle:light] Building all-in-one image ${image_tag}..."
docker build --build-arg "APP_VERSION=${app_version}" -f docker/all-in-one.Dockerfile -t "${image_tag}" .
echo "[bundle:light] Exporting .tar.xz..."
docker save "${image_tag}" | xz -T0 -3 > "${DIST_ROOT}/superset-tools.${tag}.tar.xz"
echo "[bundle:light] Calculating checksums..."
(
cd "${DIST_ROOT}"
sha256sum "superset-tools.${tag}.tar.xz" > "sha256sums-light.${tag}.txt"
)
local img_id repo_digest
img_id="$(get_image_id "${image_tag}")"
repo_digest="$(get_repo_digest "${image_tag}")"
cat > "${DIST_ROOT}/manifest-light.${tag}.txt" <<EOF
release_tag=${tag}
image=${image_tag}
image_id=${img_id}
repo_digest=${repo_digest}
archive=superset-tools.${tag}.tar.xz
compose_file=docker-compose.light.yml
checksums_file=sha256sums-light.${tag}.txt
generated_at_utc=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
EOF
cat > "${DIST_ROOT}/manifest-light.${tag}.json" <<EOF
{
"release_tag": "${tag}",
"generated_at_utc": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"image": "${image_tag}",
"image_id": "${img_id}",
"repo_digest": "${repo_digest}",
"archive": "superset-tools.${tag}.tar.xz",
"compose_file": "docker-compose.light.yml",
"checksums_file": "sha256sums-light.${tag}.txt"
}
EOF
cat > "${DIST_ROOT}/docker-compose.light.yml" <<'COMPOSE'
services:
db:
image: postgres:16-alpine
pull_policy: never
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-ss_tools}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-ss_tools}"]
interval: 10s
timeout: 5s
retries: 10
app:
image: ${IMAGE:-superset-tools:latest}
pull_policy: never
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-ss_tools}
BACKEND_PORT: 8000
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY}
SERVICE_JWT: ${SERVICE_JWT:?Set SERVICE_JWT}
MCP_ALLOWED_HOSTS: ${MCP_ALLOWED_HOSTS:-127.0.0.1:*,localhost:*,[::1]:*}
MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-http://127.0.0.1:*,http://localhost:*,http://[::1]:*}
INITIAL_ADMIN_CREATE: ${INITIAL_ADMIN_CREATE:-false}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
FEATURES__DATASET_REVIEW: ${FEATURES__DATASET_REVIEW:-true}
FEATURES__HEALTH_MONITOR: ${FEATURES__HEALTH_MONITOR:-true}
ports:
- "${APP_HOST_PORT:-8000}:8000"
volumes:
- ./storage:/app/storage
volumes:
postgres_data:
COMPOSE
cp "${SCRIPT_DIR}/.env.example" "${DIST_ROOT}/.env.light.example"
echo "[bundle:light] ✅ Lightweight bundle created in ${DIST_ROOT}"
echo " # Load image:"
echo " xz -dc ${DIST_ROOT}/superset-tools.${tag}.tar.xz | docker load"
echo " docker compose -f ${DIST_ROOT}/docker-compose.light.yml up"
echo ""
echo " Single container (without compose):"
echo " docker run -p 8000:8000 --env-file backend/.env ${image_tag}"
}
# ======================================================================
# HELP
# ======================================================================
show_help() {
cat <<'HELP'
Usage: ./build.sh <command> [options]
Commands for local docker:
up [profile] Build and start services (default: current)
down [profile] Stop and remove containers
restart [profile] Rebuild and restart services
logs [profile] Tail logs from all services
status Show running containers for all profiles
Commands for individual image build (docker build only, no export):
build:backend <tag> Build backend image (superset-tools-backend:<tag>)
build:frontend <tag> Build frontend image (superset-tools-frontend:<tag>)
Commands for single-image bundle (build + .tar.xz export):
bundle:backend <tag> Build + export backend .tar.xz only
bundle:frontend <tag> Build + export frontend .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 + postgres):
bundle <tag> Default enterprise bundle (3 .tar.xz archives).
Includes: backend + frontend + postgres (16-alpine).
PostgreSQL работает в докер-контейнере — внешний БД не требуется.
Before export: clean PostgreSQL migration + backend boot/restart smoke gate.
Runtime secrets are configured in the generated deployment .env.
Example: ./build.sh bundle v1.0.0
bundle:light <tag> Lightweight all-in-one image (.tar.xz, <200 MB, no Playwright)
Example: ./build.sh bundle:light v1.0.0
Profiles: current (default), master, enterprise-clean
Backward-compatible shortcuts:
./build.sh -> ./build.sh up current
./build.sh master -> ./build.sh up master
./build.sh enterprise-clean -> ./build.sh up enterprise-clean
Output:
Local docker: docker compose -p superset-tools-{profile}
Bundle artifacts: dist/docker/
Notes:
- 'current' and 'master' use docker-compose.yml (dev, with local postgres container).
- 'enterprise-clean' uses docker-compose.enterprise-clean.yml (build from source,
external postgres, corporate certs, optional nginx SSL).
- Individual build commands (build:backend|frontend) produce local docker
images without touching dist/docker/.
- Single-image bundle commands (bundle:backend|frontend) build + export
only one .tar.xz, useful for incremental deployments.
- Bundle command (bundle) includes postgres:16-alpine
as superset-tools-postgres:<tag> — loaded together with other images
via 'xz -dc ... | docker load'. No external PostgreSQL required.
- pip/npm dependency downloads are cached via BuildKit cache mounts and the
postgres pull is skipped when the image is already present locally.
Clear the dependency cache with: docker buildx prune
HELP
}
# ======================================================================
# MAIN DISPATCH
# ======================================================================
main() {
local CMD="${1:-up}"
shift 2>/dev/null || true
case "$CMD" in
up|down|restart|logs|status|help|-h|--help|bundle|bundle:light|smoke:bundle|\
build:backend|build:frontend|\
bundle:backend|bundle:frontend)
# Valid commands — proceed
;;
*)
# Backward compat: treat unknown arg as profile for "up"
local profile="$CMD"
CMD="up"
set -- "$profile" "$@"
;;
esac
case "$CMD" in
up) compose_up "${1:-current}" ;;
down) compose_down "${1:-current}" ;;
restart) compose_restart "${1:-current}" ;;
logs) compose_logs "${1:-current}" ;;
bundle) bundle_release "$@" ;;
bundle:light) bundle_light "$@" ;;
smoke:bundle) run_bundle_smoke "$@" ;;
build:backend) build_backend "$@" ;;
build:frontend) build_frontend "$@" ;;
bundle:backend) bundle_single_backend "$@" ;;
bundle:frontend) bundle_single_frontend "$@" ;;
status) compose_status ;;
help|-h|--help) show_help ;;
*)
echo "Error: unknown command '$CMD'. See ./build.sh help"
exit 1
;;
esac
}
main "$@"