Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b1d4311d3 |
@@ -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
|
||||
Executable
+384
@@ -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."
|
||||
Executable
+290
@@ -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
|
||||
Reference in New Issue
Block a user