lilith-platform.live/deployments/@domains/quinn.sso/deploy.sh
Natalie 6f4b9ceead feat(sso/health): report build stamp on GET /health
Mirror the @features/api build stamp on the SSO service: inject __BUILD_INFO__
(version, BUILD_COUNT, short SHA, UTC time) via bun build --define in deploy.sh
and surface it plus service + startedAt from /health. Falls back to env then
'dev' for unbundled runs.
2026-06-21 17:34:41 -05:00

324 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# quinn.sso — Deploy SSO service to vps-0
# =============================================================================
# Services deployed:
# quinn-sso-api → /opt/quinn-sso-api/ (Bun backend :3025, systemd)
# web → /var/www/quinn.sso/dist/ (static Vite SPA, nginx)
#
# Prerequisites on vps-0:
# - /etc/quinn-sso-api/secrets.env (provisioned by this script on first deploy)
# - /etc/quinn-admin-api/secrets.env (quinn.admin deployed first — JWT_SECRET shared)
# - nginx sites-enabled/ symlink (created by this script on first run)
# - Node.js 20+
#
# Usage:
# ./deploy.sh # full deploy
# ./deploy.sh --rollback # restore previous SSO API
# ./deploy.sh --skip-build # skip local typecheck/build (CI: artifacts pre-built)
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# deployments/@domains/quinn.sso/ → lilith-platform.live/ is 3 levels up
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)"
REMOTE="quinn-vps"
REMOTE_API="/opt/quinn-sso-api"
REMOTE_DIST="/var/www/quinn.sso/dist"
REMOTE_BACKUPS_API="/opt/quinn-sso-api.deploy-backups"
REMOTE_BACKUPS_DIST="/var/www/quinn.sso/.deploy-backups/dist"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_API="${REMOTE_BACKUPS_API}/${TIMESTAMP}"
BACKUP_DIST="${REMOTE_BACKUPS_DIST}/${TIMESTAMP}"
# ---------------------------------------------------------------------------
# --rollback flag: restore the most recent backup of API and dist
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--rollback" ]]; then
echo "==> [ROLLBACK] Restoring previous SSO API + frontend on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
REMOTE_BACKUPS_API="/opt/quinn-sso-api.deploy-backups"
REMOTE_BACKUPS_DIST="/var/www/quinn.sso/.deploy-backups/dist"
REMOTE_API="/opt/quinn-sso-api"
REMOTE_DIST="/var/www/quinn.sso/dist"
latest_api="$(ls -1t "$REMOTE_BACKUPS_API" 2>/dev/null | head -1)"
latest_dist="$(ls -1t "$REMOTE_BACKUPS_DIST" 2>/dev/null | head -1)"
if [[ -z "$latest_api" && -z "$latest_dist" ]]; then
echo "ERROR: no backups found for API or dist." >&2
exit 1
fi
if [[ -n "$latest_api" ]]; then
echo " Restoring API from $REMOTE_BACKUPS_API/$latest_api ..."
rsync -a --delete "$REMOTE_BACKUPS_API/$latest_api/" "$REMOTE_API/"
else
echo " WARNING: no API backup found — skipping API restore."
fi
if [[ -n "$latest_dist" ]]; then
echo " Restoring dist from $REMOTE_BACKUPS_DIST/$latest_dist ..."
rsync -a --delete "$REMOTE_BACKUPS_DIST/$latest_dist/" "$REMOTE_DIST/"
else
echo " WARNING: no dist backup found — skipping dist restore."
fi
ENDSSH
echo "==> Reloading nginx and restarting SSO service..."
ssh "$REMOTE" "nginx -t && systemctl reload nginx && systemctl restart quinn-sso-api"
echo ""
echo "Rollback completed at $(date '+%Y-%m-%d %H:%M:%S %Z')"
exit 0
fi
# ---------------------------------------------------------------------------
# --skip-build flag: artifacts pre-built in CI; skip local builds
# ---------------------------------------------------------------------------
SKIP_BUILD=false
for arg in "$@"; do [[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true; done
# ---------------------------------------------------------------------------
# Rollback trap — fires on any error after backups are created
# ---------------------------------------------------------------------------
BACKUPS_CREATED=false
rollback_on_error() {
local exit_code=$?
echo ""
echo "Deploy step failed (exit ${exit_code})."
if [[ "$BACKUPS_CREATED" == "true" ]]; then
echo "==> [AUTO-ROLLBACK] Restoring previous SSO API + dist on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH || echo " WARNING: rollback failed. Manual intervention required." >&2
set -euo pipefail
RESTORED=false
if [[ -d "${BACKUP_API}" ]]; then
rsync -a --delete "${BACKUP_API}/" "${REMOTE_API}/"
echo " API restored from ${BACKUP_API}"
RESTORED=true
fi
if [[ -d "${BACKUP_DIST}" ]]; then
rsync -a --delete "${BACKUP_DIST}/" "${REMOTE_DIST}/"
echo " dist restored from ${BACKUP_DIST}"
RESTORED=true
fi
if [[ "\$RESTORED" == "true" ]]; then
nginx -t && systemctl reload nginx
systemctl restart quinn-sso-api
echo " Services reloaded."
fi
ENDSSH
echo " Rollback complete — SSO is on the previous release."
else
echo " No backups were created — nothing to roll back."
fi
exit "$exit_code"
}
trap rollback_on_error ERR
# ---------------------------------------------------------------------------
# [1/9] Build
# ---------------------------------------------------------------------------
if [[ "$SKIP_BUILD" == false ]]; then
# Auto-bump patch version with deploy timestamp: 0.1.2-20260412_223433
_ver=$(cat "${REPO_ROOT}/VERSION.txt" | head -1)
_base=${_ver%%-*}
_major=${_base%%.*}; _rest=${_base#*.}; _minor=${_rest%%.*}; _patch=${_rest#*.}
echo "${_major}.${_minor}.$((_patch + 1))-${TIMESTAMP}" > "${REPO_ROOT}/VERSION.txt"
echo "==> [1/9] Type-checking SSO API..."
cd "$REPO_ROOT/codebase/@features/sso/backend-api" && bun run typecheck
cd "$SCRIPT_DIR"
echo "==> [1/9] Building SSO API..."
# Build stamp injected so GET /health reports which SSO build is live.
_bi_ver="$(cat "${REPO_ROOT}/VERSION.txt" | head -1)"
_bi_bc="$(cat "${REPO_ROOT}/BUILD_COUNT" 2>/dev/null | head -1)"
_bi_sha="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
_bi_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cd "$REPO_ROOT/codebase/@features/sso/backend-api" && bun build src/server.ts \
--target=node --outfile=dist/server.js \
--external=argon2 --external=otpauth --external=qrcode \
--define "__BUILD_INFO__={\"version\":\"${_bi_ver}\",\"buildCount\":\"${_bi_bc}\",\"sha\":\"${_bi_sha}\",\"builtAt\":\"${_bi_time}\"}"
cd "$SCRIPT_DIR"
echo "==> [1/9] Building seed-passphrase script..."
cd "$REPO_ROOT/codebase/@features/sso/backend-api" && bun build src/seed-passphrase.ts --target=node --outfile=dist/seed-passphrase.js --external=argon2
cd "$SCRIPT_DIR"
echo "==> [1/9] Building SSO frontend..."
cd "$REPO_ROOT/codebase/@features/sso/frontend-public" && bun run build
cd "$SCRIPT_DIR"
fi
# ---------------------------------------------------------------------------
# [2/9] Backup current API and dist on remote
# ---------------------------------------------------------------------------
echo "==> [2/9] Backing up current SSO API + dist on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH
set -euo pipefail
mkdir -p "${REMOTE_BACKUPS_API}" "${REMOTE_BACKUPS_DIST}"
if [[ -d "${REMOTE_API}" && -n "\$(ls -A '${REMOTE_API}' 2>/dev/null)" ]]; then
rsync -a --exclude='node_modules' "${REMOTE_API}/" "${BACKUP_API}/"
echo " API backup: ${BACKUP_API}"
find "${REMOTE_BACKUPS_API}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
else
echo " No existing SSO API — first deploy."
fi
if [[ -d "${REMOTE_DIST}" && -n "\$(ls -A '${REMOTE_DIST}' 2>/dev/null)" ]]; then
rsync -a "${REMOTE_DIST}/" "${BACKUP_DIST}/"
echo " dist backup: ${BACKUP_DIST}"
find "${REMOTE_BACKUPS_DIST}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
else
echo " No existing dist — first deploy."
fi
ENDSSH
BACKUPS_CREATED=true
# ---------------------------------------------------------------------------
# [3/9] Provision /etc/quinn-sso-api/secrets.env
# SSO owns JWT_SECRET — generated once on first deploy, never shared.
# All consumers (admin, my, newsletter) delegate auth to SSO via HTTP;
# none of them do local JWT verification, so they don't need JWT_SECRET.
# ---------------------------------------------------------------------------
echo "==> [3/9] Provisioning quinn-sso-api secrets..."
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
SSO_SECRETS=/etc/quinn-sso-api/secrets.env
mkdir -p /etc/quinn-sso-api
touch "$SSO_SECRETS"
chmod 600 "$SSO_SECRETS"
chown root:root "$SSO_SECRETS"
# Generate JWT_SECRET once — SSO is the sole issuer; consumers verify via HTTP
current_jwt="$(grep '^JWT_SECRET=' "$SSO_SECRETS" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "$current_jwt" || "$current_jwt" == "CHANGE_ME" ]]; then
new_jwt="$(openssl rand -hex 32)"
if grep -q '^JWT_SECRET=' "$SSO_SECRETS"; then
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=${new_jwt}|" "$SSO_SECRETS"
else
echo "JWT_SECRET=${new_jwt}" >> "$SSO_SECRETS"
fi
echo " JWT_SECRET generated."
else
echo " JWT_SECRET already set."
fi
mkdir -p /opt/quinn-sso-api/data
chown www-data:www-data /opt/quinn-sso-api/data
echo " data directory ready."
ENDSSH
# ---------------------------------------------------------------------------
# [4/9] Deploy API bundle
# ---------------------------------------------------------------------------
echo "==> [4/9] Deploying SSO API bundle to ${REMOTE}:${REMOTE_API}..."
ssh "$REMOTE" "mkdir -p ${REMOTE_API}"
rsync -avz --delete \
--exclude='src' \
--exclude='tsconfig.json' \
--exclude='node_modules' \
--exclude='.env' \
--exclude='data' \
"$REPO_ROOT/codebase/@features/sso/backend-api/" "${REMOTE}:${REMOTE_API}/"
# ---------------------------------------------------------------------------
# [5/9] Deploy frontend dist
# ---------------------------------------------------------------------------
echo "==> [5/9] Deploying SSO frontend dist to ${REMOTE}:${REMOTE_DIST}..."
ssh "$REMOTE" "mkdir -p ${REMOTE_DIST}"
rsync -avz --delete \
"$REPO_ROOT/codebase/@features/sso/frontend-public/dist/" "${REMOTE}:${REMOTE_DIST}/"
# ---------------------------------------------------------------------------
# [6/9] Install production dependencies on remote
# ---------------------------------------------------------------------------
echo "==> [6/9] Installing SSO deps LOCALLY (apricot) + rsyncing node_modules to ${REMOTE}..."
# VPS has no registry route. Same pattern as quinn.admin + quinn.ai + quinn.my.
SSO_DEPS_DIR="$(mktemp -d)"
cp "${REPO_ROOT}/codebase/@features/sso/backend-api/package.json" "$SSO_DEPS_DIR/"
[ -f "${REPO_ROOT}/codebase/@features/sso/backend-api/bun.lock" ] && cp "${REPO_ROOT}/codebase/@features/sso/backend-api/bun.lock" "$SSO_DEPS_DIR/"
printf 'registry=https://registry.npmjs.org/\n@lilith:registry=http://forge.black.lan/api/packages/lilith/npm/\n' > "$SSO_DEPS_DIR/.npmrc"
(cd "$SSO_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -5)
ssh "$REMOTE" "mkdir -p ${REMOTE_API}/node_modules"
rsync -avz --delete "$SSO_DEPS_DIR/node_modules/" "${REMOTE}:${REMOTE_API}/node_modules/"
rm -rf "${SSO_DEPS_DIR:-/nonexistent}"
# ---------------------------------------------------------------------------
# [7/9] nginx: prod.conf + enable site + maps fragment
# ---------------------------------------------------------------------------
echo "==> [7/9] Syncing nginx prod.conf (sso.transquinnftw.com)..."
scp "$SCRIPT_DIR/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/sso.transquinnftw.com"
ssh "$REMOTE" "ln -sf /etc/nginx/sites-available/sso.transquinnftw.com /etc/nginx/sites-enabled/sso.transquinnftw.com 2>/dev/null || true"
# Write the SSO rate-limit zone to quinn-maps.conf (idempotent).
# This ensures the zone exists before nginx reloads — avoids "unknown zone" errors.
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
MAPS=/etc/nginx/conf.d/quinn-maps.conf
touch "$MAPS"
if ! grep -q 'quinn_sso_login' "$MAPS"; then
echo 'limit_req_zone $binary_remote_addr zone=quinn_sso_login:10m rate=5r/m;' >> "$MAPS"
echo " Added quinn_sso_login zone to quinn-maps.conf."
else
echo " quinn_sso_login zone already present in quinn-maps.conf."
fi
ENDSSH
# ---------------------------------------------------------------------------
# [8/9] nginx test + reload
# ---------------------------------------------------------------------------
echo "==> [8/9] Testing and reloading nginx..."
ssh "$REMOTE" "nginx -t && systemctl reload nginx"
# ---------------------------------------------------------------------------
# [8b/9] Sync systemd unit + daemon-reload + enable + restart
# ---------------------------------------------------------------------------
echo "==> [8b/9] Syncing systemd unit..."
scp "$SCRIPT_DIR/systemd/quinn-sso-api.service" "${REMOTE}:/etc/systemd/system/quinn-sso-api.service"
ssh "$REMOTE" "systemctl daemon-reload && systemctl enable quinn-sso-api && systemctl restart quinn-sso-api"
# ---------------------------------------------------------------------------
# [9/9] Health check
# ---------------------------------------------------------------------------
echo "==> [9/9] Verifying SSO service is live..."
sleep 3
if ! ssh "$REMOTE" "curl -sf http://127.0.0.1:3025/health > /dev/null"; then
echo "ERROR: SSO API health check failed (http://127.0.0.1:3025/health)" >&2
exit 1
fi
echo " Internal health check passed."
if ! curl -sf --max-time 10 https://sso.transquinnftw.com/health > /dev/null; then
echo "WARN: external health check failed — verify manually: https://sso.transquinnftw.com/health" >&2
else
echo " External health check passed."
fi
# ---------------------------------------------------------------------------
# [10] Seed passphrase from vault (if vault file exists)
# ---------------------------------------------------------------------------
VAULT_FILE="$REPO_ROOT/users/transquinnftw/vault/sso.env"
if [[ -f "$VAULT_FILE" ]]; then
SSO_PASSPHRASE="$(grep '^SSO_PASSPHRASE=' "$VAULT_FILE" | cut -d= -f2-)"
if [[ -n "$SSO_PASSPHRASE" ]]; then
echo "==> [10] Seeding SSO passphrase from vault..."
ssh "$REMOTE" "cd ${REMOTE_API} && PASSPHRASE='${SSO_PASSPHRASE}' DB_PATH=${REMOTE_API}/data/quinn-sso.db node dist/seed-passphrase.js"
echo " Passphrase seeded."
else
echo "==> [10] SKIP: SSO_PASSPHRASE empty in vault file."
fi
else
echo "==> [10] SKIP: No vault file at $VAULT_FILE — seed passphrase manually."
fi
echo ""
echo "Deployed quinn.sso successfully at $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo ""
echo " SSO API: http://127.0.0.1:3025"
echo " Login: https://sso.transquinnftw.com/login"
echo " Health: https://sso.transquinnftw.com/health"
echo ""
echo "To roll back: bash $SCRIPT_DIR/deploy.sh --rollback"