#!/usr/bin/env bash # #region build [C:2] [TYPE Module] # @PURPOSE: Unified build script — local docker compose + release bundles (backend + frontend + agent) + lightweight bundle # @COMPLEXITY: 2 # # Usage: ./build.sh [options] # # Commands: # 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 # bundle Full release bundle (backend + frontend + postgres .tar.xz's) # bundle:light Lightweight all-in-one bundle (<200 MB, .tar.xz, no Playwright) # help Show this help # # Profiles: current (default), master, enterprise-clean # #endregion build set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" 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.enterprise-clean" 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 } # ====================================================================== # 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 + postgres .tar's) # ====================================================================== 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 agent_tag="superset-tools-agent:${tag}" mkdir -p "$DIST_ROOT" echo "[bundle] Building backend image ${backend_tag}..." docker build -f docker/backend.Dockerfile -t "${backend_tag}" . echo "[bundle] Building frontend image ${frontend_tag}..." docker build --build-arg "APP_VERSION=${tag}" -f docker/frontend.Dockerfile -t "${frontend_tag}" . echo "[bundle] Building agent image ${agent_tag}..." docker build -f docker/Dockerfile.agent -t "${agent_tag}" . echo "[bundle] Exporting .tar.xz archives..." echo "[bundle] Compressing backend image..." docker save "${backend_tag}" | xz -T0 -6 > "${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz" echo "[bundle] Compressing frontend image..." docker save "${frontend_tag}" | xz -T0 -6 > "${DIST_ROOT}/superset-tools-frontend.${tag}.tar.xz" echo "[bundle] Compressing agent image..." docker save "${agent_tag}" | xz -T0 -6 > "${DIST_ROOT}/superset-tools-agent.${tag}.tar.xz" echo "[bundle] Calculating checksums..." ( cd "${DIST_ROOT}" sha256sum "superset-tools-backend.${tag}.tar.xz" "superset-tools-frontend.${tag}.tar.xz" "superset-tools-agent.${tag}.tar.xz" > "sha256sums.${tag}.txt" ) local backend_id frontend_id agent_id local backend_digest frontend_digest agent_digest backend_id="$(get_image_id "${backend_tag}")" frontend_id="$(get_image_id "${frontend_tag}")" agent_id="$(get_image_id "${agent_tag}")" backend_digest="$(get_repo_digest "${backend_tag}")" frontend_digest="$(get_repo_digest "${frontend_tag}")" agent_digest="$(get_repo_digest "${agent_tag}")" # Генерируем deploy-compose для загрузки pre-built образов (image: + pull_policy: never) # Без postgres — используется внешний корпоративный PostgreSQL. cat > "${DIST_ROOT}/docker-compose.enterprise-clean.yml" < "${DIST_ROOT}/manifest.${tag}.txt" < "${DIST_ROOT}/manifest.${tag}.json" < .env.enterprise-clean, then:" echo " docker compose -f ${DIST_ROOT}/docker-compose.enterprise-clean.yml up" echo "" echo " NOTE: '${tag}' bundle uses external PostgreSQL (set POSTGRES_HOST)." 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}" mkdir -p "$DIST_ROOT" echo "[bundle:light] Building all-in-one image ${image_tag}..." docker build -f docker/all-in-one.Dockerfile -t "${image_tag}" . echo "[bundle:light] Exporting .tar.xz..." docker save "${image_tag}" | xz -T0 -6 > "${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 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.enterprise-clean.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 release bundles: bundle Build release images (backend + frontend + agent .tar.xz's). NOTE: external PostgreSQL only — no bundled postgres image. 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). - 'enterprise-clean' uses docker-compose.enterprise-clean.yml (build from source, external postgres, corporate certs, optional nginx SSL). HELP } # ====================================================================== # MAIN DISPATCH # ====================================================================== main() { local CMD="${1:-up}" shift 2>/dev/null || true case "$CMD" in up|down|restart|logs|bundle|bundle:light|status|help|-h|--help) # 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 "$@" ;; status) compose_status ;; help|-h|--help) show_help ;; *) echo "Error: unknown command '$CMD'. See ./build.sh help" exit 1 ;; esac } main "$@"