docs(deploy): 📝 Add deployment strategy documentation with detailed instructions and rationale
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8bb3f2f403
commit
a3d580c124
1 changed files with 117 additions and 55 deletions
|
|
@ -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-<svc>: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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue