MicroFish/scripts/deploy.sh

333 lines
10 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"
REMOTE_FRONTEND="/opt/foresight/frontend"
API_HEALTH_URL="https://api.foresight.yizhou.chat/health"
PROD_URL="https://foresight.yizhou.chat"
COS_BUCKET="foresight-1317962478"
FRONTEND_API_BASE_URL="${VITE_API_BASE_URL:-https://api.foresight.yizhou.chat}"
FRONTEND_DEMO_PASSWORD="${VITE_DEMO_PASSWORD:-}"
FRONTEND_DEPLOY_TARGET="${FRONTEND_DEPLOY_TARGET:-server}"
CDN_ORIGIN_IP="${CDN_ORIGIN_IP:-124.223.92.72}"
# 定位项目根目录
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
if [[ "$FRONTEND_DEPLOY_TARGET" == "cos" ]]; then
command -v coscmd >/dev/null 2>&1 || fail "coscmd 未安装 (pip install coscmd)"
ok "coscmd 可用"
[[ -f ~/.cos.conf ]] || fail "~/.cos.conf 不存在"
ok "~/.cos.conf 存在"
elif [[ "$FRONTEND_DEPLOY_TARGET" == "server" ]]; then
ok "前端部署目标: server ($SSH_HOST:$REMOTE_FRONTEND)"
else
fail "未知 FRONTEND_DEPLOY_TARGET: $FRONTEND_DEPLOY_TARGET (支持 server/cos)"
fi
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 -lc "cd /opt/foresight/backend && setsid ./.venv-311/bin/python run.py --host 0.0.0.0 >> logs/server.log 2>&1 < /dev/null &"
'
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" && \
VITE_API_BASE_URL="$FRONTEND_API_BASE_URL" \
VITE_DEMO_PASSWORD="$FRONTEND_DEMO_PASSWORD" \
npx vite build 2>&1 | tail -10 ) \
|| fail "vite build 失败"
fi
ok "构建完成"
if [[ "$FRONTEND_DEPLOY_TARGET" == "server" ]]; then
log "rsync frontend/dist → $SSH_HOST:$REMOTE_FRONTEND"
maybe rsync -az --delete \
--exclude '.DS_Store' \
-e "ssh -i $SSH_KEY" \
"$LOCAL_FRONTEND/dist/" \
"$SSH_HOST:/tmp/foresight-frontend-dist/"
maybe ssh -i "$SSH_KEY" "$SSH_HOST" \
"sudo mkdir -p '$REMOTE_FRONTEND' && sudo rsync -az --delete /tmp/foresight-frontend-dist/ '$REMOTE_FRONTEND'/ && sudo chown -R www-data:www-data '$REMOTE_FRONTEND'"
ok "已上传到云服务器静态目录"
if command -v tccli >/dev/null 2>&1 && ! $DRY_RUN; then
log "确认 CDN 回源为云服务器..."
tccli cdn UpdateDomainConfig --cli-unfold-argument \
--Domain "${PROD_URL#https://}" \
--Origin.Origins "$CDN_ORIGIN_IP" \
--Origin.OriginType ip \
--Origin.ServerName "${PROD_URL#https://}" \
--Origin.OriginPullProtocol http >/dev/null
ok "CDN 回源: $CDN_ORIGIN_IP"
fi
else
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"
fi
# 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 "$@"