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.
324 lines
14 KiB
Bash
Executable file
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"
|