Compare commits

...
Author SHA1 Message Date
Aaron Reisman 8b1d4311d3 chore(repo): add docker smoke tests 2025-10-06 07:30:22 -07:00
3 changed files with 841 additions and 0 deletions
+167
View File
@@ -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
+384
View File
@@ -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 <<EOF
Benchmark cold vs warm Docker builds for admin, space, web, live.
Usage:
$(basename "$0") [--services svc1,svc2,...] [--no-prune] [--aggressive-prune] [--help]
Options:
--services Comma-separated list of services to bench (default: $DEFAULT_SERVICES)
Valid services: ${!DOCKERFILES[@]}
--no-prune Do not prune build cache before cold builds (faster but not a true cold build)
--aggressive-prune Prune images/containers/volumes in addition to build cache (slow, but most isolated)
--verbose Show build output (default: silent)
--help Show this help
Examples:
$(basename "$0")
$(basename "$0") --services web,admin
$(basename "$0") --no-prune
$(basename "$0") --aggressive-prune
EOF
}
# ---------------------------
# Parse args
# ---------------------------
while (( "$#" )); do
case "$1" in
--services)
shift
[[ $# -gt 0 ]] || die "--services requires a value"
SERVICES="$1"
;;
--no-prune)
NO_PRUNE="true"
;;
--aggressive-prune)
AGGRESSIVE_PRUNE="true"
;;
--verbose)
VERBOSE="1"
;;
--help|-h)
print_usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
shift
done
# Resolve conflicting options
if [[ "$AGGRESSIVE_PRUNE" == "true" && "$NO_PRUNE" == "true" ]]; then
log "Both --no-prune and --aggressive-prune specified; ignoring --no-prune."
NO_PRUNE="false"
fi
# ---------------------------
# Preconditions
# ---------------------------
have docker || die "Docker is required. Please install Docker and ensure the daemon is running."
# Verify Docker daemon is reachable
if ! docker info >/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."
+290
View File
@@ -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 <<EOF
Usage: $(basename "$0") [up|down] [--no-build] [--services svc1,svc2,...]
Commands:
up Build (unless --no-build) and run admin, live, space, web. Probe readiness and leave running.
down Stop and remove the smoke containers.
Options:
--no-build Do not rebuild images (reuse existing).
--services Comma-separated list of services to run (default: web,admin,space,live).
--help Show this help.
Environment overrides:
Ports: WEB_PORT=${WEB_PORT}, ADMIN_PORT=${ADMIN_PORT}, SPACE_PORT=${SPACE_PORT}, LIVE_PORT=${LIVE_PORT}
Paths: WEB_PATH=${WEB_PATH}, ADMIN_PATH=${ADMIN_PATH}, SPACE_PATH=${SPACE_PATH}, LIVE_PATH=${LIVE_PATH}
Timeouts: MAX_WAIT=${MAX_WAIT}, SLEEP_INTERVAL=${SLEEP_INTERVAL}
Images: WEB_IMAGE=${WEB_IMAGE}, ADMIN_IMAGE=${ADMIN_IMAGE}, SPACE_IMAGE=${SPACE_IMAGE}, LIVE_IMAGE=${LIVE_IMAGE}
Names: WEB_CONT=${WEB_CONT}, ADMIN_CONT=${ADMIN_CONT}, SPACE_CONT=${SPACE_CONT}, LIVE_CONT=${LIVE_CONT}
Examples:
$(basename "$0") up
$(basename "$0") up --services web,space --no-build
$(basename "$0") down
EOF
}
# ---------------------------
# Main
# ---------------------------
cmd="${1:-up}"
no_build="false"
# Default list; CLI --services should take precedence over environment.
services="web,admin,space,live"
shift $(( $# > 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