diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000..dfadc29a --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# ============================================================================ +# Foresight 先见之明 - 一键部署脚本 +# ============================================================================ +# +# 用法: +# ./scripts/deploy.sh # 默认: 部署后端 + 重启 Flask +# ./scripts/deploy.sh --backend # 只部署后端 +# ./scripts/deploy.sh --frontend # 只部署前端 (build + COS + CDN purge) +# ./scripts/deploy.sh --full # 后端 + 前端全量部署 +# ./scripts/deploy.sh --no-restart # 部署后端但不重启 Flask +# ./scripts/deploy.sh --dry-run # 只显示会做什么,不实际执行 +# +# 前置要求: +# - SSH key: /Users/liyizhouai/Desktop/openclaw/船长/liyizhouAI.pem +# - SSH passwordless sudo on ubuntu@124.223.92.72 +# - coscmd 已安装并配置 (~/.cos.conf 指向 foresight-1317962478) +# - tccli 已安装并配置 (用于 CDN 刷新) +# - 本地有 rsync +# +# 脚本保证: +# - 后端 Python 语法检查后才上传 +# - Flask 重启后自动 verify /health,失败则报错 +# - 前端 vite build 失败则停止 +# - 不上传 .venv/ / __pycache__/ / uploads/ 等运行时文件 +# ============================================================================ + +set -euo pipefail + +# ---------- 配置 ---------- +SSH_KEY="/Users/liyizhouai/Desktop/openclaw/船长/liyizhouAI.pem" +SSH_HOST="ubuntu@124.223.92.72" +REMOTE_BACKEND="/opt/foresight/backend" +API_HEALTH_URL="https://api.foresight.yizhou.chat/health" +PROD_URL="https://foresight.yizhou.chat" +COS_BUCKET="foresight-1317962478" + +# 定位项目根目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_BACKEND="$PROJECT_ROOT/backend" +LOCAL_FRONTEND="$PROJECT_ROOT/frontend" + +# ---------- 参数 ---------- +DEPLOY_BACKEND=true +DEPLOY_FRONTEND=false +RESTART_FLASK=true +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --backend) DEPLOY_BACKEND=true; DEPLOY_FRONTEND=false ;; + --frontend) DEPLOY_BACKEND=false; DEPLOY_FRONTEND=true ;; + --full) DEPLOY_BACKEND=true; DEPLOY_FRONTEND=true ;; + --no-restart) RESTART_FLASK=false ;; + --dry-run) DRY_RUN=true ;; + -h|--help) + sed -n '2,20p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "❌ 未知参数: $arg" + echo "用 --help 查看用法" + exit 1 + ;; + esac +done + +# ---------- 颜色 ---------- +C_BLUE='\033[0;34m' +C_GREEN='\033[0;32m' +C_YELLOW='\033[0;33m' +C_RED='\033[0;31m' +C_DIM='\033[2m' +C_RESET='\033[0m' + +log() { printf "${C_BLUE}▶${C_RESET} %s\n" "$*"; } +ok() { printf "${C_GREEN}✓${C_RESET} %s\n" "$*"; } +warn() { printf "${C_YELLOW}⚠${C_RESET} %s\n" "$*"; } +fail() { printf "${C_RED}✗${C_RESET} %s\n" "$*" >&2; exit 1; } +section() { printf "\n${C_BLUE}=== %s ===${C_RESET}\n" "$*"; } + +maybe() { + if $DRY_RUN; then + printf "${C_DIM}(dry-run) %s${C_RESET}\n" "$*" + else + "$@" + fi +} + +# ---------- 前置检查 ---------- +preflight() { + section "前置检查" + + [[ -f "$SSH_KEY" ]] || fail "SSH key 不存在: $SSH_KEY" + ok "SSH key 存在" + + ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$SSH_HOST" 'echo ok' >/dev/null 2>&1 \ + || fail "SSH 连接失败" + ok "SSH 可达 $SSH_HOST" + + if $DEPLOY_FRONTEND; then + command -v coscmd >/dev/null 2>&1 || fail "coscmd 未安装 (pip install coscmd)" + ok "coscmd 可用" + [[ -f ~/.cos.conf ]] || fail "~/.cos.conf 不存在" + ok "~/.cos.conf 存在" + command -v tccli >/dev/null 2>&1 || warn "tccli 未安装,跳过 CDN 自动刷新" + fi + + if $DEPLOY_BACKEND; then + [[ -d "$LOCAL_BACKEND" ]] || fail "本地 backend 目录不存在" + ok "本地 backend: $LOCAL_BACKEND" + fi + + if $DEPLOY_FRONTEND; then + [[ -d "$LOCAL_FRONTEND" ]] || fail "本地 frontend 目录不存在" + ok "本地 frontend: $LOCAL_FRONTEND" + fi +} + +# ---------- 后端语法检查 ---------- +backend_syntax_check() { + log "Python 语法检查..." + local failed=0 + while IFS= read -r f; do + if ! python3 -m py_compile "$f" 2>/dev/null; then + warn "语法错误: $f" + failed=1 + fi + done < <(find "$LOCAL_BACKEND/app" "$LOCAL_BACKEND/scripts" -name '*.py' -type f 2>/dev/null) + (( failed == 0 )) || fail "语法检查未通过,部署中止" + ok "全部通过" +} + +# ---------- 后端 rsync ---------- +deploy_backend() { + section "部署后端" + backend_syntax_check + + log "rsync backend/app → $SSH_HOST:$REMOTE_BACKEND/app" + maybe rsync -az --delete \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + --exclude '.pytest_cache/' \ + -e "ssh -i $SSH_KEY" \ + --rsync-path="sudo rsync" \ + "$LOCAL_BACKEND/app/" \ + "$SSH_HOST:$REMOTE_BACKEND/app/" + + log "rsync backend/scripts → $SSH_HOST:$REMOTE_BACKEND/scripts" + maybe rsync -az --delete \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + -e "ssh -i $SSH_KEY" \ + --rsync-path="sudo rsync" \ + "$LOCAL_BACKEND/scripts/" \ + "$SSH_HOST:$REMOTE_BACKEND/scripts/" + + log "chown ubuntu:ubuntu" + maybe ssh -i "$SSH_KEY" "$SSH_HOST" \ + "sudo chown -R ubuntu:ubuntu $REMOTE_BACKEND/app $REMOTE_BACKEND/scripts" + + ok "后端代码已同步" +} + +# ---------- 重启 Flask ---------- +restart_flask() { + section "重启 Flask" + + if $DRY_RUN; then + warn "dry-run: 不实际重启 Flask" + return + fi + + log "kill 旧 Flask 进程..." + ssh -i "$SSH_KEY" "$SSH_HOST" ' + OLD=$(sudo ss -tlnp 2>/dev/null | grep 5001 | grep -oP "pid=\K[0-9]+" | head -1) + if [[ -n "$OLD" ]]; then + echo "killing PID $OLD" + sudo kill -9 "$OLD" 2>/dev/null || true + else + echo "no old Flask running" + fi + ' + sleep 2 + + log "启动新 Flask..." + ssh -i "$SSH_KEY" "$SSH_HOST" ' + sudo -u ubuntu bash -c "cd /opt/foresight/backend && nohup ./.venv-311/bin/python run.py --host 0.0.0.0 >> logs/server.log 2>&1 < /dev/null & disown" + ' + sleep 4 + + log "验证 /health..." + local attempts=0 + local max_attempts=5 + while (( attempts < max_attempts )); do + if curl -sf -m 5 "$API_HEALTH_URL" >/dev/null 2>&1; then + local new_pid + new_pid=$(ssh -i "$SSH_KEY" "$SSH_HOST" 'sudo ss -tlnp 2>/dev/null | grep 5001 | grep -oP "pid=\K[0-9]+" | head -1') + ok "Flask 已就绪 (PID $new_pid)" + return 0 + fi + attempts=$(( attempts + 1 )) + sleep 2 + done + fail "Flask 健康检查超时 ($max_attempts 次尝试失败)" +} + +# ---------- 前端构建 + 部署 ---------- +deploy_frontend() { + section "部署前端" + + log "vite build..." + if $DRY_RUN; then + warn "dry-run: 跳过 vite build" + else + ( cd "$LOCAL_FRONTEND" && npx vite build 2>&1 | tail -10 ) \ + || fail "vite build 失败" + fi + ok "构建完成" + + log "coscmd upload dist/ → cos://$COS_BUCKET/" + maybe bash -c "cd '$LOCAL_FRONTEND' && coscmd upload -r dist/ / --ignore .DS_Store" 2>&1 | tail -15 + ok "已上传到 COS" + + # CDN 刷新 (可选) + if command -v tccli >/dev/null 2>&1; then + log "刷新 CDN 缓存..." + if $DRY_RUN; then + warn "dry-run: 跳过 CDN 刷新" + else + local task_id + task_id=$(tccli cdn PurgePathCache --cli-unfold-argument \ + --Paths "$PROD_URL/" --FlushType flush 2>&1 \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('TaskId','unknown'))" 2>/dev/null \ + || echo "failed") + ok "CDN 刷新任务: $task_id (1-3 分钟生效)" + fi + else + warn "tccli 未安装,需要手动刷新 CDN" + fi +} + +# ---------- 最终验证 ---------- +final_verify() { + section "最终验证" + + if $DEPLOY_BACKEND && ! $DRY_RUN; then + log "API health check..." + if curl -sf -m 5 "$API_HEALTH_URL" >/dev/null 2>&1; then + ok "API $API_HEALTH_URL" + else + warn "API 不可达: $API_HEALTH_URL" + fi + fi + + if $DEPLOY_FRONTEND && ! $DRY_RUN; then + log "前端 HTML check..." + if curl -sfI -m 5 "$PROD_URL" 2>&1 | head -1 | grep -q "200"; then + ok "前端 $PROD_URL" + else + warn "前端 HTML 不可达: $PROD_URL" + fi + fi +} + +# ---------- main ---------- +main() { + section "Foresight 部署" + printf "Mode: " + $DEPLOY_BACKEND && printf "backend " + $DEPLOY_FRONTEND && printf "frontend " + $RESTART_FLASK && printf "+restart " + $DRY_RUN && printf "(DRY-RUN) " + printf "\n" + + preflight + + if $DEPLOY_BACKEND; then + deploy_backend + $RESTART_FLASK && restart_flask + fi + + if $DEPLOY_FRONTEND; then + deploy_frontend + fi + + final_verify + + section "部署完成" + ok "全部步骤成功" +} + +main "$@"