From 8b1d4311d3cc64d5826379048135559b0a795d2a Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Mon, 6 Oct 2025 07:28:49 -0700 Subject: [PATCH] chore(repo): add docker smoke tests --- .github/workflows/docker-smoke.yml | 167 +++++++++++++ scripts/bench-builds.sh | 384 +++++++++++++++++++++++++++++ scripts/smoke.sh | 290 ++++++++++++++++++++++ 3 files changed, 841 insertions(+) create mode 100644 .github/workflows/docker-smoke.yml create mode 100755 scripts/bench-builds.sh create mode 100755 scripts/smoke.sh diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml new file mode 100644 index 00000000000..4346c10b9d8 --- /dev/null +++ b/.github/workflows/docker-smoke.yml @@ -0,0 +1,167 @@ +name: Docker build and smoke test for apps + +on: + workflow_dispatch: + pull_request: + branches: ["preview"] + paths: + - "apps/web/**" + - "apps/space/**" + - "apps/admin/**" + - "apps/live/**" + - "packages/**" + - "turbo.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/docker-smoke.yml" + push: + branches: ["preview"] + paths: + - "apps/web/**" + - "apps/space/**" + - "apps/admin/**" + - "apps/live/**" + - "packages/**" + - "turbo.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/docker-smoke.yml" + +permissions: + contents: read + +concurrency: + group: docker-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + determine-matrix: + name: Determine matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.build-matrix.outputs.matrix }} + has_targets: ${{ steps.build-matrix.outputs.has_targets }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed paths + id: changes + uses: dorny/paths-filter@v3 + with: + filters: | + web: + - 'apps/web/**' + space: + - 'apps/space/**' + admin: + - 'apps/admin/**' + live: + - 'apps/live/**' + common: + - 'turbo.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/workflows/docker-smoke.yml' + - 'packages/**' + + - name: Build matrix + id: build-matrix + uses: actions/github-script@v7 + with: + script: | + const include = []; + const eventName = context.eventName; + const anyCommon = '${{ steps.changes.outputs.common }}' === 'true'; + const changed = { + web: '${{ steps.changes.outputs.web }}' === 'true', + space: '${{ steps.changes.outputs.space }}' === 'true', + admin: '${{ steps.changes.outputs.admin }}' === 'true', + live: '${{ steps.changes.outputs.live }}' === 'true', + }; + const add = (name, dockerfile, image, container, port, path, env_flags = "") => include.push({ name, dockerfile, image, container, host_port: port, path, env_flags }); + const buildAll = anyCommon || eventName === 'push' || eventName === 'workflow_dispatch'; + if (buildAll || changed.web) add('web', 'apps/web/Dockerfile.web', 'plane-web:ci-smoke', 'plane-web-ci', 3001, '/'); + if (buildAll || changed.space) add('space', 'apps/space/Dockerfile.space', 'plane-space:ci-smoke', 'plane-space-ci', 3002, '/spaces'); + if (buildAll || changed.admin) add('admin', 'apps/admin/Dockerfile.admin', 'plane-admin:ci-smoke', 'plane-admin-ci', 3003, '/god-mode'); + if (buildAll || changed.live) add('live', 'apps/live/Dockerfile.live', 'plane-live:ci-smoke', 'plane-live-ci', 3004, '/live/health', '-e NODE_ENV=production -e LIVE_BASE_PATH=/live'); + const hasTargets = include.length > 0; + if (!hasTargets) { + core.warning('No changes detected for any app. Matrix is empty. Skipping smoke tests.'); + } + core.setOutput('matrix', JSON.stringify({ include })); + core.setOutput('has_targets', String(hasTargets)); + smoke: + name: Build and smoke test ${{ matrix.name }} + runs-on: ubuntu-latest + needs: determine-matrix + if: ${{ needs.determine-matrix.outputs.has_targets == 'true' }} + timeout-minutes: 25 + + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.determine-matrix.outputs.matrix) }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Show Docker version + run: | + docker version + docker info + + - name: Run scripts/smoke.sh for ${{ matrix.name }} + shell: bash + run: | + set -euo pipefail + svc="${{ matrix.name }}" + case "$svc" in + web) + export WEB_PORT=${{ matrix.host_port }} + export WEB_PATH='${{ matrix.path }}' + ;; + admin) + export ADMIN_PORT=${{ matrix.host_port }} + export ADMIN_PATH='${{ matrix.path }}' + export ADMIN_BASE_PATH='/god-mode' + ;; + space) + export SPACE_PORT=${{ matrix.host_port }} + export SPACE_PATH='${{ matrix.path }}' + export SPACE_BASE_PATH='/spaces' + ;; + live) + export LIVE_PORT=${{ matrix.host_port }} + export LIVE_PATH='${{ matrix.path }}' + export LIVE_BASE_PATH='/live' + ;; + esac + export MAX_WAIT=120 + export SLEEP_INTERVAL=2 + export NODE_ENV=production + set +e + output="$(bash scripts/smoke.sh up --services "$svc")" + status=$? + set -e + echo "$output" + if [ $status -ne 0 ]; then + echo "smoke.sh exited non-zero ($status)" + exit $status + fi + if echo "$output" | grep -qi "failure(s)"; then + echo "::group::Container logs ($svc)" + docker ps -a || true + docker logs "plane-${svc}-smoke" || true + echo "::endgroup::" + exit 1 + fi + + - name: Cleanup smoke containers (${{ matrix.name }}) + if: always() + run: | + bash scripts/smoke.sh down diff --git a/scripts/bench-builds.sh b/scripts/bench-builds.sh new file mode 100755 index 00000000000..b42f03de633 --- /dev/null +++ b/scripts/bench-builds.sh @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# plane/scripts/bench-builds.sh +# +# Benchmark cold vs warm Docker image builds (time and space) for: +# - admin +# - space +# - web +# - live +# +# What it measures: +# - Cold build time (after pruning build cache and removing target image) +# - Warm build time (immediately rebuilding with cache) +# - Image size (bytes -> human readable) +# - Build cache size before/after (best-effort using buildx or system df) +# +# Usage: +# ./scripts/bench-builds.sh # run all services +# ./scripts/bench-builds.sh --services web,admin +# ./scripts/bench-builds.sh --no-prune # do not prune build cache prior to cold build +# ./scripts/bench-builds.sh --aggressive-prune # prune images/containers/volumes (heavy) +# ./scripts/bench-builds.sh --help +# +# Environment vars: +# DOCKER_BUILDKIT=1 is set automatically for builds in this script. + +set -Eeuo pipefail + +# Require Bash 4+ early (macOS default /bin/bash is 3.2) +if [[ -z "${BASH_VERSINFO:-}" || "${BASH_VERSINFO[0]:-0}" -lt 4 ]]; then + { + echo "ERROR: This script requires Bash 4+ but your shell appears to be Bash ${BASH_VERSION:-unknown}." + echo + echo "On macOS, the default /bin/bash is 3.2. Install a newer Bash (e.g., via Homebrew):" + echo " brew install bash" + echo + echo "Then run this script with the newer Bash explicitly, for example:" + echo " /usr/local/bin/bash $0 [args...] # Intel mac" + echo " /opt/homebrew/bin/bash $0 [args...] # Apple Silicon" + } >&2 + exit 1 +fi + +# --------------------------- +# Config and service catalog +# --------------------------- +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +declare -A DOCKERFILES=( + [web]="apps/web/Dockerfile.web" + [admin]="apps/admin/Dockerfile.admin" + [space]="apps/space/Dockerfile.space" + [live]="apps/live/Dockerfile.live" +) + +declare -A TAGS=( + [web]="plane-web:bench" + [admin]="plane-admin:bench" + [space]="plane-space:bench" + [live]="plane-live:bench" +) + +DEFAULT_SERVICES="web,admin,space,live" + +# Options +SERVICES="${SERVICES:-$DEFAULT_SERVICES}" +NO_PRUNE="false" +AGGRESSIVE_PRUNE="false" +VERBOSE="0" + +# --------------------------- +# Helpers +# --------------------------- +have() { command -v "$1" >/dev/null 2>&1; } + +log() { printf '[%s] %s\n' "$(date +'%H:%M:%S')" "$*"; } + +die() { echo "ERROR: $*" >&2; exit 1; } + +now_ns() { + # Robust nanosecond epoch getter with output validation. + # Order: gdate -> date (validate numeric) -> EPOCHREALTIME -> perl Time::HiRes -> seconds*1e9 + local ts + + # 1) GNU date if available (gdate on macOS/Homebrew) + if have gdate; then + ts="$(gdate +%s%N 2>/dev/null || true)" + if [[ "$ts" =~ ^[0-9]+$ ]]; then + echo "$ts" + return 0 + fi + fi + + # 2) POSIX/BSD date with %N (BSD prints literal "%N" but exits 0). Validate numeric. + ts="$(date +%s%N 2>/dev/null || true)" + if [[ "$ts" =~ ^[0-9]+$ ]]; then + echo "$ts" + return 0 + fi + + # 3) Bash 5+ EPOCHREALTIME: "seconds.microseconds" -> ns + if [[ -n "${EPOCHREALTIME:-}" ]]; then + local sec frac micro + sec="${EPOCHREALTIME%.*}" + frac="${EPOCHREALTIME#*.}" + if [[ "$sec" =~ ^[0-9]+$ ]]; then + frac="${frac%%[^0-9]*}" # keep only digits from fractional part + micro="${frac}000000" # pad to at least 6 digits (microseconds) + micro="${micro:0:6}" + echo $(( sec * 1000000000 + micro * 1000 )) + return 0 + fi + fi + + # 4) Perl Time::HiRes as a portable high-resolution fallback + if have perl; then + ts="$(perl -MTime::HiRes=time -e 'printf "%.0f\n", time()*1e9' 2>/dev/null || true)" + if [[ "$ts" =~ ^[0-9]+$ ]]; then + echo "$ts" + return 0 + fi + fi + + # 5) Final fallback: seconds * 1e9 (integer) + echo "$(( $(date +%s) * 1000000000 ))" +} + +elapsed_ms() { + local start_ns="$1" end_ns="$2" + # integer division, keep ms precision + echo $(( (end_ns - start_ns) / 1000000 )) +} + +human_ms() { + local ms="$1" + if (( ms < 1000 )); then + printf "%dms" "$ms" + else + # print seconds with 2 decimals + awk "BEGIN { printf \"%.2fs\", $ms/1000 }" + fi +} + +human_bytes() { + local bytes="${1:-0}" + awk -v b="$bytes" 'BEGIN { + if (b < 0) b = 0 + split("B KB MB GB TB PB", u, " ") + i = 1 + while (b >= 1024 && i < 6) { b /= 1024; i++ } + printf("%.2f %s", b, u[i]) + }' 2>/dev/null || printf "%s %s" "${bytes}" "B" +} + +image_size_bytes() { + local tag="$1" + docker image inspect -f '{{.Size}}' "$tag" 2>/dev/null || echo "0" +} + +# Best-effort build cache size (string), returns "N/A" if unavailable +build_cache_size_str() { + if have docker && docker buildx version >/dev/null 2>&1; then + # Try buildx du summary (not standardized across versions; best effort parsing) + local du + du="$(docker buildx du 2>/dev/null || true)" + if [[ -n "$du" ]]; then + # Look for a line with total size summary; fallback to whole output + local line size + line="$(echo "$du" | tail -n 1)" + # Heuristic parse: e.g., "total: 1.23GB (123MB in use)" + size="$(echo "$line" | awk '{for(i=1;i<=NF;i++){if($i ~ /[0-9.]+(GB|MB|KB|B)/){print $i; exit}}}')" + if [[ -n "$size" ]]; then + echo "$size (buildx du)" + return 0 + fi + # Fallback: return the last line + echo "$line (buildx du)" + return 0 + fi + fi + + # Fallback to docker system df + if have docker; then + local df + df="$(docker system df 2>/dev/null || true)" + if [[ -n "$df" ]]; then + # Try to extract "Build cache" row size column + # Example line may look like: + # "Build cache 3.14GB 1.23GB" + local row + row="$(echo "$df" | awk '/Build cache/ {print}')" + if [[ -n "$row" ]]; then + local size + size="$(echo "$row" | awk '{for(i=1;i<=NF;i++){if($i ~ /[0-9.]+(GB|MB|KB|B)/){print $i; exit}}}')" + if [[ -n "$size" ]]; then + echo "$size (system df)" + return 0 + fi + echo "$row (system df)" + return 0 + fi + fi + fi + echo "N/A" +} + +prune_build_cache() { + if [[ "$AGGRESSIVE_PRUNE" == "true" ]]; then + log "Aggressive prune: images/containers/volumes/build-cache" + docker system prune -af --volumes >/dev/null 2>&1 || true + else + log "Pruning builder cache only" + docker builder prune -af >/dev/null 2>&1 || true + fi +} + +remove_image_if_exists() { + local tag="$1" + if docker image inspect "$tag" >/dev/null 2>&1; then + docker rmi -f "$tag" >/dev/null 2>&1 || true + fi +} + +build_service() { + local svc="$1" tag="$2" dockerfile_rel="$3" cold="$4" + local dockerfile="$ROOT_DIR/$dockerfile_rel" + [[ -f "$dockerfile" ]] || die "Dockerfile not found for $svc at $dockerfile_rel" + + local start end elapsed + start="$(now_ns)" + if [[ "${VERBOSE:-0}" == "1" ]]; then + if [[ "$cold" == "true" ]]; then + DOCKER_BUILDKIT=1 docker build --no-cache --progress=plain -f "$dockerfile" -t "$tag" "$ROOT_DIR" + else + DOCKER_BUILDKIT=1 docker build --progress=plain -f "$dockerfile" -t "$tag" "$ROOT_DIR" + fi + else + if [[ "$cold" == "true" ]]; then + DOCKER_BUILDKIT=1 docker build --no-cache --progress=plain -f "$dockerfile" -t "$tag" "$ROOT_DIR" >/dev/null + else + DOCKER_BUILDKIT=1 docker build --progress=plain -f "$dockerfile" -t "$tag" "$ROOT_DIR" >/dev/null + fi + fi + end="$(now_ns)" + elapsed="$(elapsed_ms "$start" "$end")" + echo "$elapsed" +} + +print_usage() { + cat </dev/null 2>&1; then + die "Docker daemon is not running or is not reachable. Please start Docker (e.g., Docker Desktop) and try again." +fi + +# Normalize service list +IFS=',' read -r -a SELECTED <<< "$SERVICES" + +# Validate services +for s in "${SELECTED[@]}"; do + [[ -n "${DOCKERFILES[$s]:-}" ]] || die "Unknown service: $s" +done + +# --------------------------- +# Bench +# --------------------------- +declare -A COLD_TIME_MS +declare -A WARM_TIME_MS +declare -A COLD_IMG_SIZE +declare -A WARM_IMG_SIZE + +log "Benchmarking services: ${SELECTED[*]}" +log "Build cache size (before): $(build_cache_size_str)" + +for svc in "${SELECTED[@]}"; do + tag="${TAGS[$svc]}" + df_rel="${DOCKERFILES[$svc]}" + + log "=== $svc: preparing cold build ===" + remove_image_if_exists "$tag" + if [[ "$NO_PRUNE" != "true" ]]; then + prune_build_cache + else + log "Skipping build cache prune (--no-prune)" + fi + + log "$svc: cold build start" + ct="$(build_service "$svc" "$tag" "$df_rel" "true")" + COLD_TIME_MS["$svc"]="$ct" + COLD_IMG_SIZE["$svc"]="$(image_size_bytes "$tag")" + log "$svc: cold build done in $(human_ms "$ct"), image size=$(human_bytes "${COLD_IMG_SIZE[$svc]}")" + + log "$svc: warm build start" + wt="$(build_service "$svc" "$tag" "$df_rel" "false")" + WARM_TIME_MS["$svc"]="$wt" + WARM_IMG_SIZE["$svc"]="$(image_size_bytes "$tag")" + log "$svc: warm build done in $(human_ms "$wt"), image size=$(human_bytes "${WARM_IMG_SIZE[$svc]}")" +done + +log "Build cache size (after): $(build_cache_size_str)" + +# --------------------------- +# Summary +# --------------------------- +echo +echo "================ Build Bench Summary ================" +printf "%-8s | %-12s | %-12s | %-12s | %-12s\n" "Service" "Cold Time" "Warm Time" "Cold Image" "Warm Image" +printf -- "---------+--------------+--------------+--------------+--------------\n" +for svc in "${SELECTED[@]}"; do + printf "%-8s | %-12s | %-12s | %-12s | %-12s\n" \ + "$svc" \ + "$(human_ms "${COLD_TIME_MS[$svc]}")" \ + "$(human_ms "${WARM_TIME_MS[$svc]}")" \ + "$(human_bytes "${COLD_IMG_SIZE[$svc]}")" \ + "$(human_bytes "${WARM_IMG_SIZE[$svc]}")" +done +echo "=====================================================" +echo +echo "Notes:" +echo "- Cold builds prune build cache (unless --no-prune) and remove target images." +echo "- Warm builds immediately rebuild with cache populated from the cold build." +echo "- Build cache size uses docker buildx du if available; otherwise a docker system df heuristic." +echo +echo "Done." diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 00000000000..6db56c29e65 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# Local smoke test for Next.js apps: admin, live, space, web +# - Builds images using each app's Dockerfile +# - Runs each container on a unique host port +# - Probes HTTP endpoints for readiness +# - Leaves containers running for manual inspection + +set -Eeuo pipefail + +# --------------------------- +# Config (customize via env) +# --------------------------- +WEB_PORT="${WEB_PORT:-3001}" +ADMIN_PORT="${ADMIN_PORT:-3002}" +SPACE_PORT="${SPACE_PORT:-3003}" +LIVE_PORT="${LIVE_PORT:-3004}" + +# Probe paths (derive from base paths; can be overridden) +WEB_PATH="${WEB_PATH:-/}" +ADMIN_PATH="${ADMIN_PATH:-${ADMIN_BASE_PATH:-/god-mode}}" +SPACE_PATH="${SPACE_PATH:-${SPACE_BASE_PATH:-/spaces}}" +LIVE_PATH="${LIVE_PATH:-${LIVE_BASE_PATH:-/live}/health}" + +# Wait up to N seconds for each service to be ready +MAX_WAIT="${MAX_WAIT:-120}" +SLEEP_INTERVAL="${SLEEP_INTERVAL:-2}" + +# Build args (override to change the built-in defaults) +# e.g. ADMIN_BASE_PATH="/admin" SPACE_BASE_PATH="/my-space" +ADMIN_BASE_PATH="${ADMIN_BASE_PATH:-/god-mode}" +SPACE_BASE_PATH="${SPACE_BASE_PATH:-/spaces}" +LIVE_BASE_PATH="${LIVE_BASE_PATH:-/live}" + +# Docker image tags and container names +WEB_IMAGE="${WEB_IMAGE:-plane-web:smoke}" +ADMIN_IMAGE="${ADMIN_IMAGE:-plane-admin:smoke}" +SPACE_IMAGE="${SPACE_IMAGE:-plane-space:smoke}" +LIVE_IMAGE="${LIVE_IMAGE:-plane-live:smoke}" + +WEB_CONT="${WEB_CONT:-plane-web-smoke}" +ADMIN_CONT="${ADMIN_CONT:-plane-admin-smoke}" +SPACE_CONT="${SPACE_CONT:-plane-space-smoke}" +LIVE_CONT="${LIVE_CONT:-plane-live-smoke}" + +# --------------------------- +# Utilities +# --------------------------- +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +have() { command -v "$1" >/dev/null 2>&1; } + +log() { printf '[%s] %s\n' "$(date +'%H:%M:%S')" "$*"; } + +die() { echo "ERROR: $*" >&2; exit 1; } + +http_ok() { + # Returns 0 if URL returns a 2xx/3xx, non-zero otherwise + local url="$1" + if have curl; then + # Use silent mode; output HTTP code only + local code + code="$(curl --connect-timeout 3 --max-time 10 -fsS -o /dev/null -w "%{http_code}" "$url" || true)" + case "$code" in + 2??|3??) return 0 ;; + *) return 1 ;; + esac + elif have wget; then + wget -q --timeout=10 --tries=1 --spider "$url" >/dev/null 2>&1 + return $? + else + die "Neither curl nor wget is available to probe endpoints" + fi +} + +wait_for_url() { + local url="$1" name="$2" waited=0 + log "Waiting for $name to be ready at: $url (timeout: ${MAX_WAIT}s)" + until http_ok "$url"; do + sleep "$SLEEP_INTERVAL" + waited=$((waited + SLEEP_INTERVAL)) + if (( waited >= MAX_WAIT )); then + log "Timed out waiting for $name at $url" + # Aid triage: print last response body (first 200 lines) + if have curl; then + echo "---- Last response body (${url}) ----" + curl -sS -L "${url}" | sed -n '1,200p' || true + elif have wget; then + echo "---- Last response body (${url}) ----" + wget -qO- "${url}" | sed -n '1,200p' || true + fi + return 1 + fi + done + log "$name is ready at $url" +} + +ensure_not_running() { + local name="$1" + if docker ps -a --format '{{.Names}}' | grep -q "^${name}\$"; then + log "Container ${name} already exists, removing..." + docker rm -f "$name" >/dev/null 2>&1 || true + fi +} + +build_image() { + local name="$1" image="$2" dockerfile="$3" + shift 3 + local build_args=("$@") + log "Building ${name} image (${image}) with Dockerfile: ${dockerfile}" + DOCKER_BUILDKIT=1 docker build \ + --pull \ + --progress=plain \ + -f "$dockerfile" \ + -t "$image" \ + "${build_args[@]}" \ + "$ROOT_DIR" +} + +run_container() { + local name="$1" image="$2" host_port="$3" + shift 3 + local extra_args=("$@") + log "Starting ${name} on host port ${host_port} from image ${image}" + ensure_not_running "$name" + docker run -d --rm \ + --name "$name" \ + -p "${host_port}:3000" \ + -e NODE_ENV=production \ + "${extra_args[@]}" \ + "$image" >/dev/null +} + +stop_containers() { + local names=("$@") + for n in "${names[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -q "^${n}\$"; then + log "Stopping ${n}..." + docker rm -f "$n" >/dev/null 2>&1 || true + fi + done +} + +usage() { + cat < 0 ? 1 : 0 )) + +while (( "$#" )); do + case "$1" in + --no-build) no_build="true"; shift ;; + --services) [[ $# -ge 2 ]] || die "--services requires a value"; services="$2"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) die "Unknown argument: $1" ;; + esac +done + +# Environment override (only if CLI didn't specify --services) +if [[ -n "${SERVICES:-}" && "$services" == "web,admin,space,live" ]]; then + services="$SERVICES" +fi + +case "$cmd" in + down) + stop_containers "$WEB_CONT" "$ADMIN_CONT" "$SPACE_CONT" "$LIVE_CONT" + log "All smoke containers stopped." + exit 0 + ;; + up) ;; + *) + usage + exit 1 + ;; +esac + +# Check Docker is available +have docker || die "Docker is required. Please install Docker and ensure the daemon is running." +# Verify daemon availability early +docker ps -q >/dev/null 2>&1 || die "Docker daemon not reachable. Start Docker and retry." + +cd "$ROOT_DIR" + +IFS=',' read -r -a SELECTED <<< "${services//[[:space:]]/}" + +if [[ "$no_build" != "true" ]]; then + for svc in "${SELECTED[@]}"; do case "$svc" in + web) build_image "web" "$WEB_IMAGE" "apps/web/Dockerfile.web" ;; + admin) build_image "admin" "$ADMIN_IMAGE" "apps/admin/Dockerfile.admin" --build-arg NEXT_PUBLIC_ADMIN_BASE_PATH="${ADMIN_BASE_PATH}" ;; + space) build_image "space" "$SPACE_IMAGE" "apps/space/Dockerfile.space" --build-arg NEXT_PUBLIC_SPACE_BASE_PATH="${SPACE_BASE_PATH}" ;; + live) build_image "live" "$LIVE_IMAGE" "apps/live/Dockerfile.live" --build-arg LIVE_BASE_PATH="${LIVE_BASE_PATH}" ;; + *) die "Unknown service: $svc" ;; + esac; done +else log "Skipping build (reusing existing images)"; fi + +# Run containers (selected) +for svc in "${SELECTED[@]}"; do + case "$svc" in + web) run_container "$WEB_CONT" "$WEB_IMAGE" "$WEB_PORT" ;; + admin) run_container "$ADMIN_CONT" "$ADMIN_IMAGE" "$ADMIN_PORT" ;; + space) run_container "$SPACE_CONT" "$SPACE_IMAGE" "$SPACE_PORT" ;; + live) run_container "$LIVE_CONT" "$LIVE_IMAGE" "$LIVE_PORT" -e LIVE_BASE_PATH="${LIVE_BASE_PATH}" ;; + *) die "Unknown service: $svc" ;; + esac +done + +# Probe readiness +web_url="http://127.0.0.1:${WEB_PORT}${WEB_PATH}" +admin_url="http://127.0.0.1:${ADMIN_PORT}${ADMIN_PATH}" +space_url="http://127.0.0.1:${SPACE_PORT}${SPACE_PATH}" +live_url="http://127.0.0.1:${LIVE_PORT}${LIVE_PATH}" + +failures=0 +for svc in "${SELECTED[@]}"; do + case "$svc" in + web) wait_for_url "$web_url" "web" || failures=$((failures+1)) ;; + admin) wait_for_url "$admin_url" "admin" || failures=$((failures+1)) ;; + space) wait_for_url "$space_url" "space" || failures=$((failures+1)) ;; + live) wait_for_url "$live_url" "live" || failures=$((failures+1)) ;; + esac +done + +echo +if (( failures == 0 )); then + log "Smoke test succeeded. Containers are running:" +else + log "Smoke test completed with ${failures} failure(s). Containers may still be running for inspection:" + # Dump brief logs to help triage + for svc in "${SELECTED[@]}"; do + case "$svc" in + web) name="$WEB_CONT" ;; + admin) name="$ADMIN_CONT" ;; + space) name="$SPACE_CONT" ;; + live) name="$LIVE_CONT" ;; + esac + docker ps -a --format '{{.Names}}' | grep -q "^${name}\$" && { + log "---- logs: ${name} (last 80 lines) ----" + docker logs --tail=80 "$name" || true + } + done +fi + +docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E "(^NAMES|${WEB_CONT}|${ADMIN_CONT}|${SPACE_CONT}|${LIVE_CONT})" || true + +echo +echo "URLs:" +for svc in "${SELECTED[@]}"; do + case "$svc" in + web) echo " web: ${web_url}" ;; + admin) echo " admin: ${admin_url}" ;; + space) echo " space: ${space_url}" ;; + live) echo " live: ${live_url}" ;; + esac +done + +echo +echo "To stop all smoke containers:" +echo " $(basename "$0") down" + +if (( failures > 0 )); then + exit 1 +fi