companion/@deployments/quinn.ai/deploy.sh

274 lines
11 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# quinn.ai — Deploy companion-api + PWA frontend to vps-0
# =============================================================================
# Deploys:
# companion-api → /var/www/quinn.ai/api/ (NestJS :3850, systemd)
# web PWA → /var/www/quinn.ai/dist/ (static Vite SPA, nginx)
#
# Prerequisites on vps-0:
# - /etc/quinn-ai/companion-api.env (copy env/vps-0.env.example, fill values)
# - /etc/quinn-ai/htpasswd (htpasswd -c /etc/quinn-ai/htpasswd quinn)
# - Docker Compose running companion-postgres + companion-redis
# - wg-quick@wg1 enabled + active (wg1 → apricot 10.9.0.2)
# - nginx sites-enabled/ symlink (created by this script on first run)
# - Node.js 20+
#
# Usage:
# ./deploy.sh # full deploy
# ./deploy.sh --rollback # restore previous api backup
# ./deploy.sh --skip-build # skip local build (CI: artifacts pre-built)
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# deployments/quinn.ai/ → @companion/ is 2 levels up
REPO_ROOT="$(cd "$SCRIPT_DIR/../../" && pwd)"
API_DIR="${REPO_ROOT}/@applications/api"
WEB_DIR="${REPO_ROOT}/@applications/web"
REMOTE="quinn-vps"
REMOTE_API="/var/www/quinn.ai/api"
REMOTE_WEB="/var/www/quinn.ai/dist"
REMOTE_BACKUPS="/opt/quinn-ai.deploy-backups"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_PATH="${REMOTE_BACKUPS}/${TIMESTAMP}"
# ---------------------------------------------------------------------------
# --rollback: restore the most recent api backup
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--rollback" ]]; then
echo "==> [ROLLBACK] Restoring previous companion-api on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
REMOTE_BACKUPS="/opt/quinn-ai.deploy-backups"
REMOTE_API="/var/www/quinn.ai/api"
latest="$(ls -1t "$REMOTE_BACKUPS" 2>/dev/null | head -1)"
if [[ -z "$latest" ]]; then
echo "ERROR: no backups found in $REMOTE_BACKUPS" >&2
exit 1
fi
echo " Restoring from $REMOTE_BACKUPS/$latest ..."
rsync -a --delete "$REMOTE_BACKUPS/$latest/" "$REMOTE_API/"
echo " Restored successfully."
ENDSSH
echo "==> Restarting service and reloading nginx..."
ssh "$REMOTE" "sudo systemctl restart quinn-ai-companion-api && sudo nginx -t && sudo systemctl reload nginx"
echo ""
echo "Rollback completed at $(date '+%Y-%m-%d %H:%M:%S %Z')"
exit 0
fi
# ---------------------------------------------------------------------------
# --skip-build
# ---------------------------------------------------------------------------
SKIP_BUILD=false
for arg in "$@"; do [[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true; done
# ---------------------------------------------------------------------------
# Rollback trap
# ---------------------------------------------------------------------------
BACKUP_CREATED=false
rollback_on_error() {
local exit_code=$?
echo ""
echo "Deploy step failed (exit ${exit_code})."
if [[ "$BACKUP_CREATED" == "true" ]]; then
echo "==> [AUTO-ROLLBACK] Restoring ${BACKUP_PATH}${REMOTE_API} ..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH || echo " WARNING: rollback also failed — manual intervention required." >&2
set -euo pipefail
if [[ -d "${BACKUP_PATH}" ]]; then
rsync -a --delete "${BACKUP_PATH}/" "${REMOTE_API}/"
echo " companion-api restored from ${BACKUP_PATH}"
sudo systemctl restart quinn-ai-companion-api
echo " Service restarted."
fi
ENDSSH
echo " Rollback complete."
else
echo " No backup was created — nothing to roll back."
fi
exit "$exit_code"
}
trap rollback_on_error ERR
# ---------------------------------------------------------------------------
# [0/8] Pre-flight checks
# ---------------------------------------------------------------------------
echo "==> [0/8] Pre-flight checks..."
# Verify source dirs exist
for dir in "$API_DIR" "$WEB_DIR"; do
if [[ ! -d "$dir" ]]; then
echo "ERROR: source directory not found: $dir" >&2
exit 1
fi
done
echo " Source directories OK."
# Verify apricot reachable over WireGuard
echo " Checking apricot (10.9.0.2) over wg1..."
if ! ssh "$REMOTE" "ping -c 1 -W 3 10.9.0.2 > /dev/null 2>&1"; then
echo "ERROR: vps-0 cannot reach apricot at 10.9.0.2 — is wg-quick@wg1 active on vps-0?" >&2
echo " Run: ssh $REMOTE 'systemctl is-active wg-quick@wg1'" >&2
exit 1
fi
echo " Apricot reachable over wg1."
# Quick model-boss sanity check (claude:haiku is the cheapest/fastest)
echo " Checking model-boss (10.9.0.2:8210) via vps-0..."
if ! ssh "$REMOTE" "curl -sS -m 10 -o /dev/null -w '%{http_code}' http://10.9.0.2:8210/v1/models" | grep -q "200"; then
echo "ERROR: model-boss not responding at http://10.9.0.2:8210/v1/models" >&2
exit 1
fi
echo " model-boss OK."
# Verify env file exists on vps-0
if ! ssh "$REMOTE" "test -f /etc/quinn-ai/companion-api.env" 2>/dev/null; then
echo "ERROR: /etc/quinn-ai/companion-api.env not found on ${REMOTE}." >&2
echo " Copy env/vps-0.env.example → /etc/quinn-ai/companion-api.env and fill in values." >&2
exit 1
fi
echo " companion-api.env present."
# Verify htpasswd exists
if ! ssh "$REMOTE" "test -f /etc/quinn-ai/htpasswd" 2>/dev/null; then
echo "ERROR: /etc/quinn-ai/htpasswd not found on ${REMOTE}." >&2
echo " Run: ssh $REMOTE 'mkdir -p /etc/quinn-ai && htpasswd -c /etc/quinn-ai/htpasswd quinn'" >&2
exit 1
fi
echo " htpasswd present."
# ---------------------------------------------------------------------------
# [1/8] Build
# ---------------------------------------------------------------------------
if [[ "$SKIP_BUILD" == false ]]; then
echo "==> [1/8] Building companion-api (NestJS → dist/)..."
cd "$API_DIR" && pnpm build
cd "$SCRIPT_DIR"
echo "==> [1/8] Building companion-web PWA (Vite → dist/)..."
cd "$WEB_DIR" && pnpm build
cd "$SCRIPT_DIR"
else
echo "==> [1/8] Build skipped (--skip-build)."
fi
# Build standalone node_modules for VPS (no registry access there)
echo "==> [1b/8] Resolving companion-api production deps for VPS..."
API_DEPS_DIR="$(mktemp -d)"
cp "$API_DIR/package.json" "$API_DEPS_DIR/"
# If the project has a .npmrc pointing at Verdaccio, copy it
[[ -f "$API_DIR/.npmrc" ]] && cp "$API_DIR/.npmrc" "$API_DEPS_DIR/"
(cd "$API_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3)
# ---------------------------------------------------------------------------
# [2/8] Backup current api on remote
# ---------------------------------------------------------------------------
echo "==> [2/8] Backing up current companion-api on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH
set -euo pipefail
mkdir -p "${REMOTE_BACKUPS}"
if [[ -d "${REMOTE_API}" && -n "\$(ls -A '${REMOTE_API}' 2>/dev/null)" ]]; then
rsync -a --exclude='node_modules' "${REMOTE_API}/" "${BACKUP_PATH}/"
echo " Backup: ${BACKUP_PATH}"
find "${REMOTE_BACKUPS}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
else
echo " No existing companion-api — first deploy."
fi
ENDSSH
BACKUP_CREATED=true
# ---------------------------------------------------------------------------
# [3/8] Deploy companion-api
# ---------------------------------------------------------------------------
echo "==> [3/8] Deploying companion-api to ${REMOTE}:${REMOTE_API}..."
ssh "$REMOTE" "mkdir -p ${REMOTE_API}"
rsync -avz --delete \
--exclude='src' \
--exclude='test' \
--exclude='tsconfig*.json' \
--exclude='node_modules' \
--exclude='.env*' \
--exclude='*.spec.js' \
--exclude='*.test.js' \
"$API_DIR/" "${REMOTE}:${REMOTE_API}/"
rsync -avz --delete "${API_DEPS_DIR}/node_modules/" "${REMOTE}:${REMOTE_API}/node_modules/"
rm -rf "${API_DEPS_DIR:-/nonexistent}"
# ---------------------------------------------------------------------------
# [4/8] Deploy web PWA
# ---------------------------------------------------------------------------
echo "==> [4/8] Deploying web PWA to ${REMOTE}:${REMOTE_WEB}..."
ssh "$REMOTE" "mkdir -p ${REMOTE_WEB}"
rsync -avz --delete "$WEB_DIR/dist/" "${REMOTE}:${REMOTE_WEB}/"
# ---------------------------------------------------------------------------
# [5/8] Install systemd unit
# ---------------------------------------------------------------------------
echo "==> [5/8] Syncing systemd unit..."
scp "$SCRIPT_DIR/systemd/quinn-ai-companion-api.service" "${REMOTE}:/etc/systemd/system/quinn-ai-companion-api.service"
ssh "$REMOTE" "sudo systemctl daemon-reload && sudo systemctl enable quinn-ai-companion-api"
# ---------------------------------------------------------------------------
# [6/8] nginx: sync prod.conf + enable site + rate-limit maps
# ---------------------------------------------------------------------------
echo "==> [6/8] Syncing nginx prod.conf..."
scp "$SCRIPT_DIR/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/quinn.ai"
ssh "$REMOTE" "sudo ln -sf /etc/nginx/sites-available/quinn.ai /etc/nginx/sites-enabled/quinn.ai 2>/dev/null || true"
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
MAP_CONF=/etc/nginx/conf.d/quinn-ai-maps.conf
sudo tee "$MAP_CONF" > /dev/null <<'EOF'
# Managed by quinn.ai deploy.sh
limit_req_zone $binary_remote_addr zone=quinn_ai_req:10m rate=60r/m;
limit_conn_zone $binary_remote_addr zone=quinn_ai_conn:10m;
EOF
echo " Wrote quinn-ai-maps.conf."
ENDSSH
# ---------------------------------------------------------------------------
# [7/8] nginx test + reload
# ---------------------------------------------------------------------------
echo "==> [7/8] Testing and reloading nginx..."
ssh "$REMOTE" "sudo nginx -t && sudo systemctl reload nginx"
# ---------------------------------------------------------------------------
# [8/8] Restart service + health checks
# ---------------------------------------------------------------------------
echo "==> [8/8] Restarting companion-api..."
ssh "$REMOTE" "sudo systemctl restart quinn-ai-companion-api"
sleep 4
echo "==> [8/8] Health checks..."
if ! ssh "$REMOTE" "curl -sf http://127.0.0.1:3850/health > /dev/null"; then
echo "ERROR: companion-api health check failed (http://127.0.0.1:3850/health)" >&2
echo " Check logs: ssh $REMOTE 'journalctl -u quinn-ai-companion-api -n 50'" >&2
exit 1
fi
echo " companion-api healthy."
# External HTTPS health check (requires htpasswd credentials)
HTPASSWD_USER="$(ssh "$REMOTE" "head -1 /etc/quinn-ai/htpasswd | cut -d: -f1" 2>/dev/null)"
if [[ -n "$HTPASSWD_USER" ]]; then
echo " External HTTPS check: provide password when prompted (or skip with Ctrl-C)..."
if curl -sf --max-time 10 -u "${HTPASSWD_USER}" \
https://ai.transquinnftw.com/health > /dev/null 2>&1; then
echo " External HTTPS health check passed."
else
echo " WARN: external health check skipped (basic-auth credentials required)."
echo " Verify manually: curl -u quinn https://ai.transquinnftw.com/health"
fi
fi
echo ""
echo "Deployed quinn.ai successfully at $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo ""
echo " companion-api: http://127.0.0.1:3850"
echo " model-boss: http://10.9.0.2:8210 (over wg1)"
echo " chatterbox-tts: http://10.9.0.2:8000 (over wg1)"
echo " web: https://ai.transquinnftw.com"
echo ""
echo "To roll back: bash $SCRIPT_DIR/deploy.sh --rollback"