feat: add one-click deploy script

scripts/deploy.sh unifies backend/frontend deployment:
- --backend (default): rsync app+scripts → ubuntu@124.223.92.72:/opt/foresight/backend
  + kill/restart Flask + health check
- --frontend: vite build + coscmd upload to COS bucket foresight-1317962478
  + tccli CDN purge
- --full: both
- --no-restart: skip Flask restart
- --dry-run: preview only

Safety:
- Python syntax check before rsync (aborts on error)
- health check loop after restart (aborts if /health fails 5x)
- rsync --delete excludes __pycache__ / *.pyc / .pytest_cache
- rsync --rsync-path="sudo rsync" for remote permissions
- set -euo pipefail throughout
- "$@" not eval (handles spaces in project path)

Solves:
- Manual deploys of backend bits scattered across sessions missed v0.3
  accelerate methods on server until this was caught in production
- No consistent way to push frontend + purge CDN in one step
This commit is contained in:
liyizhouAI 2026-04-16 08:21:52 +08:00
parent 7443082846
commit 925d2f7ca6
1 changed files with 294 additions and 0 deletions

294
scripts/deploy.sh Executable file
View File

@ -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 "$@"