diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 8ca0dd0..83804b9 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,18 +1,25 @@ #!/usr/bin/env bash # ============================================================================= -# @analytics — Deploy to vps-0 (1984 hosting) +# @analytics — Deploy to vps-0 # ============================================================================= -# Usage: ./scripts/deploy.sh -# or via: ./run deploy +# Build images on apricot, ship via docker save | ssh | docker load, then +# `docker compose up -d --no-build` on the VPS. # -# Requires: quinn-vps SSH alias configured in ~/.ssh/config +# Why: vps-0 has 4 GB RAM. Running `docker compose --build` there OOM-kills +# nginx (incident 2026-05-15). Apricot has the headroom and the source. # # Strategy: -# - Services are built locally (turbo) — dist/ files are pre-compiled. -# - dist/ is rsynced to VPS alongside Dockerfiles; no build step needed on VPS. -# - Docker images are built on VPS from pre-compiled dist/ via docker compose --build. -# - @lilith/* workspace deps are compiled into dist/ by SWC — stripped from -# package.json in each Dockerfile so npm install only fetches registry packages. +# 1. bun run build:services (TS → dist on apricot) +# 2. .vendor-lilith/ staging (registry @lilith/* deps, VPS can't reach Verdaccio) +# 3. docker compose build (apricot — produces infrastructure-:latest) +# 4. docker save | zstd | ssh (stream images to VPS, decompress, load) +# 5. rsync compose + init.sql (in case schema/compose changed) +# 6. docker compose up -d --no-build (VPS — uses already-loaded images) +# 7. Smoke health endpoints +# +# Usage: ./scripts/deploy.sh [svc1 svc2 ...] +# No args: deploy all build-using services. +# With args: deploy only the named services (faster iteration). # ============================================================================= set -euo pipefail @@ -20,45 +27,58 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REMOTE="quinn-vps" REMOTE_DIR="~/analytics" +COMPOSE_REL="infrastructure/docker-compose.prod.yaml" +PROJECT="infrastructure" # docker compose project name (= dir name) -echo "==> [1/5] Building services..." -cd "$ROOT_DIR" && bun run build:services +ALL_SERVICES=(collector processor api website-bff realtime) +if [[ $# -gt 0 ]]; then + SERVICES=("$@") +else + SERVICES=("${ALL_SERVICES[@]}") +fi -echo "==> [2/5] Staging @lilith registry packages for Docker builds..." -# SWC transpiles but doesn't bundle — registry @lilith/* packages (non-workspace) -# still need to exist in node_modules at runtime. The VPS can't reach Verdaccio, -# so we resolve them locally and stage into .vendor-lilith/ per service. The -# Dockerfile copies these into node_modules/ before npm install. -for svc_dir in "$ROOT_DIR"/services/*/; do - svc_name="$(basename "$svc_dir")" +# Validate requested services +for svc in "${SERVICES[@]}"; do + if ! printf '%s\n' "${ALL_SERVICES[@]}" | grep -qx "${svc}"; then + echo "ERROR: unknown service '${svc}'. Valid: ${ALL_SERVICES[*]}" >&2 + exit 1 + fi +done + +echo "==> Deploying services: ${SERVICES[*]}" + +# --------------------------------------------------------------------------- +# [1/6] Compile TS → dist for each service +# --------------------------------------------------------------------------- +echo "==> [1/6] bun run build:services..." +cd "$ROOT_DIR" +bun run build:services + +# --------------------------------------------------------------------------- +# [2/6] Stage @lilith registry deps into each service's .vendor-lilith/ +# --------------------------------------------------------------------------- +echo "==> [2/6] Staging @lilith registry deps for Docker COPY..." +for svc in "${SERVICES[@]}"; do + svc_dir="${ROOT_DIR}/services/${svc}/" vendor_dir="${svc_dir}.vendor-lilith" rm -rf "$vendor_dir" mkdir -p "$vendor_dir" - # Recursively resolve @lilith registry deps (non-workspace) and their transitive - # @lilith deps into .vendor-lilith/ so the Docker image has everything it needs. - # Uses require.resolve from the svc dir to follow bun's hoisting chain. node -e " const fs = require('fs'); const path = require('path'); const svcDir = '${svc_dir}'; const vendorDir = '${vendor_dir}'; - const svcName = '${svc_name}'; - + const svcName = '${svc}'; function stagePackage(name) { const dst = path.join(vendorDir, ...name.split('/')); - if (fs.existsSync(dst)) return; // already staged - // Find the package by walking up from svcDir checking: - // 1. node_modules/@scope/pkg (standard symlink) - // 2. node_modules/.bun/@scope+pkg@*/node_modules/@scope/pkg (bun store) + if (fs.existsSync(dst)) return; const parts = name.split('/'); - const bunKey = parts.join('+'); // @lilith/foo → @lilith+foo + const bunKey = parts.join('+'); let real = null; let search = path.resolve(svcDir); while (search !== '/') { - // Standard location const candidate = path.join(search, 'node_modules', ...parts); if (fs.existsSync(candidate)) { real = fs.realpathSync(candidate); break; } - // Bun store — glob for versioned directory const bunDir = path.join(search, 'node_modules', '.bun'); if (fs.existsSync(bunDir)) { const match = fs.readdirSync(bunDir).find(d => d.startsWith(bunKey + '@')); @@ -69,20 +89,14 @@ for svc_dir in "$ROOT_DIR"/services/*/; do } search = path.dirname(search); } - if (!real) { - console.warn(' WARN: ' + name + ' not found in any node_modules up from ' + svcName); - return; - } + if (!real) { console.warn(' WARN: ' + name + ' not found from ' + svcName); return; } fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.cpSync(real, dst, { recursive: true }); - console.log(' Staged ' + name + ' → .vendor-lilith/ (' + svcName + ')'); - // Recurse into this package's @lilith deps const child = JSON.parse(fs.readFileSync(path.join(real, 'package.json'), 'utf8')); for (const [dep] of Object.entries(child.dependencies || {})) { if (dep.startsWith('@lilith/')) stagePackage(dep); } } - const p = JSON.parse(fs.readFileSync(svcDir + 'package.json', 'utf8')); for (const [name, ver] of Object.entries(p.dependencies || {})) { if (name.startsWith('@lilith/') && typeof ver === 'string' && !ver.startsWith('workspace:')) { @@ -92,29 +106,77 @@ for svc_dir in "$ROOT_DIR"/services/*/; do " done -echo "==> [3/5] Syncing to $REMOTE:$REMOTE_DIR ..." -# Include dist/ — Docker images copy from pre-built dist, no VPS build needed -rsync -avz --delete \ - --exclude=node_modules \ - --exclude=.env \ - --exclude=.env.* \ - "$ROOT_DIR/services/" "$REMOTE:$REMOTE_DIR/services/" +# --------------------------------------------------------------------------- +# [3/6] Build images on apricot (NOT on the VPS — OOM risk) +# --------------------------------------------------------------------------- +echo "==> [3/6] Building Docker images on apricot..." +# Use a throwaway env file so compose doesn't warn about runtime-only vars. +TMP_ENV="$(mktemp)" +trap 'rm -f "$TMP_ENV"' EXIT +{ + echo "POSTGRES_USER=build" + echo "POSTGRES_PASSWORD=build" + echo "POSTGRES_DB=build" + echo "REDIS_PASSWORD=build" + echo "CORS_ORIGINS=build" + echo "COLLECTOR_WRITE_KEY=build" + echo "API_KEYS=build" + echo "ADMIN_URL=http://build" +} > "$TMP_ENV" +cd "$ROOT_DIR" +docker compose -f "$COMPOSE_REL" --env-file "$TMP_ENV" -p "$PROJECT" build "${SERVICES[@]}" +# --------------------------------------------------------------------------- +# [4/6] Ship images to vps-0 (compressed save → stream → load) +# --------------------------------------------------------------------------- +echo "==> [4/6] Shipping images to ${REMOTE}..." +for svc in "${SERVICES[@]}"; do + image="${PROJECT}-${svc}:latest" + size="$(docker image inspect "$image" --format '{{.Size}}' 2>/dev/null | numfmt --to=iec)" + echo " -> ${image} (${size:-?})" + docker save "$image" \ + | zstd -T0 -q \ + | ssh -o ControlPath=none "$REMOTE" "zstd -d -q | docker load" +done + +# --------------------------------------------------------------------------- +# [5/6] Sync compose + init.sql; bring up stack with --no-build +# --------------------------------------------------------------------------- +echo "==> [5/6] Syncing compose config + bringing up stack..." rsync -avz \ "$ROOT_DIR/infrastructure/docker-compose.prod.yaml" \ "$ROOT_DIR/infrastructure/init.sql" \ "$REMOTE:$REMOTE_DIR/infrastructure/" +ssh -o ControlPath=none "$REMOTE" "cd $REMOTE_DIR && docker compose -f infrastructure/docker-compose.prod.yaml --env-file infrastructure/.env.prod -p $PROJECT up -d --no-build --remove-orphans" -echo "==> [4/5] Rebuilding and restarting Docker stack..." -ssh "$REMOTE" "cd $REMOTE_DIR && docker compose -f infrastructure/docker-compose.prod.yaml --env-file infrastructure/.env.prod up -d --build" - -echo "==> [5/5] Health check..." -sleep 8 -ssh "$REMOTE" "curl -sf http://localhost:4001/health && echo 'collector OK' || echo 'collector NOT READY'" -ssh "$REMOTE" "curl -sf http://localhost:4003/health && echo 'api OK' || echo 'api NOT READY'" -ssh "$REMOTE" "curl -sf http://localhost:4005/health && echo 'website-bff OK' || echo 'website-bff NOT READY'" +# --------------------------------------------------------------------------- +# [6/6] Health smoke +# --------------------------------------------------------------------------- +echo "==> [6/6] Health smoke (10s settle)..." +sleep 10 +declare -A PORTS=( [collector]=4001 [api]=4003 [website-bff]=4005 ) +fail=0 +for svc in "${SERVICES[@]}"; do + port="${PORTS[$svc]:-}" + if [[ -z "$port" ]]; then + echo " ${svc}: (no health endpoint to check)" + continue + fi + if ssh -o ControlPath=none "$REMOTE" "curl -sf --max-time 5 http://localhost:${port}/health >/dev/null"; then + echo " ${svc} (:${port}): OK" + else + echo " ${svc} (:${port}): NOT READY" + fail=1 + fi +done echo "" -echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S %Z')" -echo "Collector: https://data.transquinnftw.com/analytics/track/" -echo "API: https://data.transquinnftw.com/api/" +if [[ $fail -eq 0 ]]; then + echo "Deployed at $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo " Collector: https://data.transquinnftw.com/analytics/track/" + echo " API: https://data.transquinnftw.com/api/" + echo " Rollup: https://data.cocotte.maison/ (basic-auth)" +else + echo "WARN: one or more services did not respond healthy. Check: ssh $REMOTE 'docker compose -p $PROJECT logs --tail=50'" + exit 1 +fi