#!/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 [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 Build backend image only (superset-tools-backend:) # build:frontend Build frontend image only (superset-tools-frontend:) # # Commands (single-image bundle — build + .tar.xz export): # bundle:backend Build + export backend .tar.xz only # bundle:frontend Build + export frontend .tar.xz only # # Commands (release smoke): # smoke:bundle # Validate clean PostgreSQL migration + backend boot/restart. # # Commands (full bundles — backend + frontend + postgres): # bundle Enterprise bundle (backend + frontend + postgres). Default. # bundle:light 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}" != "" ]]; 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 " 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 " 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" < "${DIST_ROOT}/manifest.${tag}${suffix}.txt" < "${DIST_ROOT}/manifest.${tag}${suffix}.json" < "${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 (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 (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" < "${DIST_ROOT}/manifest-light.${tag}.json" < "${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 [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 Build backend image (superset-tools-backend:) build:frontend Build frontend image (superset-tools-frontend:) Commands for single-image bundle (build + .tar.xz export): bundle:backend Build + export backend .tar.xz only bundle:frontend Build + export frontend .tar.xz only Release verification: smoke:bundle Run clean PostgreSQL migration + backend boot/restart gate. Commands for full bundles (backend + frontend + postgres): bundle 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 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: — 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 "$@"