274 lines
11 KiB
Bash
Executable file
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"
|