From 44aa62da491be61f18692b0b04024e27aeb495c8 Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Sun, 19 Apr 2026 00:20:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E4=BC=98=E5=8C=96=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=84=9A=E6=9C=AC=EF=BC=8C=E6=B7=BB=E5=8A=A0=20dry-ru?= =?UTF-8?q?n=E3=80=81=E5=A4=87=E4=BB=BD=E3=80=81SSH=20=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 20 ++- deploy.sh | 311 +++++++++++++++++++++++++++++++++++++++------ test_deployment.sh | 57 +++++---- 3 files changed, 324 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87fa33c..67a9ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -336,10 +336,26 @@ let new_expires = base_time + days(pkg_days); ```bash ./deploy.sh development # 部署到测试服务器 -./deploy.sh production # 部署到生产服务器 +./deploy.sh production # 部署到生产服务器 ``` -**deploy.sh 功能**:依赖检查 → 编译 → 上传二进制/配置 → 重启服务 → 部署后测试 +**deploy.sh 选项:** + +| 选项 | 说明 | +|------|------| +| `--dry-run` | 预览模式,不执行实际操作 | +| `--yes, -y` | 跳过确认提示 | +| `--skip-tests` | 跳过部署后测试 | +| `--help, -h` | 显示帮助信息 | + +**示例:** +```bash +./deploy.sh production --dry-run # 预览生产部署 +./deploy.sh production --yes # 无需确认直接部署 +./deploy.sh development --skip-tests # 跳过测试 +``` + +**deploy.sh 功能**:依赖检查 → 编译 → 备份旧版本 → 上传二进制/配置 → 重启服务 → 部署后测试 ### 前端部署 diff --git a/deploy.sh b/deploy.sh index 5a11594..1da711f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,5 +1,16 @@ #!/bin/bash +# =========================================== +# Rust Backend Deployment Script +# =========================================== +# Usage: ./deploy.sh [development|production] [options] +# +# Options: +# --dry-run 预览模式,不执行实际操作 +# --yes, -y 跳过确认提示 +# --skip-tests 跳过部署后测试 +# =========================================== + set -euo pipefail PROJECT_NAME="rust-backend" @@ -7,15 +18,52 @@ REMOTE_USER="root" REMOTE_HOST="1panel-server" APP_ENV="${1:-production}" +# 默认选项 +DRY_RUN=false +SKIP_CONFIRM=false +SKIP_TESTS=false + +# 解析选项 +shift 2>/dev/null || true +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + ;; + --yes|-y) + SKIP_CONFIRM=true + ;; + --skip-tests) + SKIP_TESTS=true + ;; + --help|-h) + echo "用法: $0 [development|production] [options]" + echo "" + echo "选项:" + echo " --dry-run 预览模式,不执行实际操作" + echo " --yes, -y 跳过确认提示" + echo " --skip-tests 跳过部署后测试" + exit 0 + ;; + *) + echo "未知选项: $1" + exit 1 + ;; + esac + shift +done + # 根据环境选择远程目录和服务 case "${APP_ENV}" in development) REMOTE_DIR="/root/rust/rust_backend_dev" SERVICE_NAME="rust-backend-dev.service" + TEST_DOMAIN="https://xmclassmate.top/dev" ;; production) REMOTE_DIR="/root/rust/rust_backend" SERVICE_NAME="rust-backend.service" + TEST_DOMAIN="https://xmclassmate.top" ;; *) echo "无效的环境: ${APP_ENV}" @@ -24,88 +72,275 @@ case "${APP_ENV}" in ;; esac -log_info() { - echo -e "\033[32m[INFO]\033[0m $1" -} - -log_error() { - echo -e "\033[31m[ERROR]\033[0m $1" >&2 - exit 1 +# 彩色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } +log_dry() { echo -e "${YELLOW}[DRY-RUN]${NC} $1"; } + +# 获取 Git 版本信息 +get_git_info() { + local git_branch + local git_commit + local git_version + + git_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + git_version="${git_branch}@${git_commit}" + + echo "$git_version" } +# 检查依赖 check_dependencies() { + log_step "检查依赖..." local deps=("cargo" "rsync" "ssh") for cmd in "${deps[@]}"; do if ! command -v "$cmd" &> /dev/null; then log_error "依赖命令 '$cmd' 未找到,请先安装。" fi done - log_info "所有依赖项检查通过。" + log_info "依赖检查通过" } -build_project() { - log_info "开始编译项目 (release模式),环境=${APP_ENV}..." - if APP_ENV="${APP_ENV}" cargo build --release; then - log_info "项目编译成功。" - else - log_error "项目编译失败!" +# 获取远程服务器 SSH ControlMaster socket 路径 +get_ssh_socket() { + echo "/tmp/ssh-master-${REMOTE_HOST}-$$" +} + +# 建立 SSH 连接(复用) +ssh_connect() { + if [ "$DRY_RUN" = true ]; then + log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) -o ControlPersist=5 ${REMOTE_USER}@${REMOTE_HOST}" + return 0 + fi + + local socket + socket=$(get_ssh_socket) + + # 检查是否已有连接 + if ! ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \ + -o "ControlMaster=no" -o "ControlPersist=no" \ + "${REMOTE_USER}@${REMOTE_HOST}" "echo 'connection test'" &>/dev/null; then + + log_info "建立 SSH 连接..." + ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \ + -o ConnectionAttempts=3 -o ConnectTimeout=10 \ + -N "${REMOTE_USER}@${REMOTE_HOST}" & + sleep 2 fi } -upload_binary() { +# 关闭 SSH 连接 +ssh_disconnect() { + if [ "$DRY_RUN" = true ]; then + return 0 + fi + + local socket + socket=$(get_ssh_socket) + ssh -o "ControlPath=${socket}" -O exit "${REMOTE_USER}@${REMOTE_HOST}" 2>/dev/null || true +} + +# 编译项目 +build_project() { + log_step "编译项目 (release模式)..." + if [ "$DRY_RUN" = true ]; then + log_dry "APP_ENV=\"${APP_ENV}\" cargo build --release" + return 0 + fi + + if APP_ENV="${APP_ENV}" cargo build --release; then + log_info "编译成功" + else + log_error "编译失败!" + fi +} + +# 备份旧版本 +backup_old_version() { + log_step "备份旧版本..." local binary_path="./target/release/${PROJECT_NAME}" + local timestamp + timestamp=$(date +%Y%m%d_%H%M%S) + local backup_path="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}" + + if [ "$DRY_RUN" = true ]; then + log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} ${backup_path}; fi'" + return 0 + fi + + ssh "${REMOTE_USER}@${REMOTE_HOST}" " + if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then + cp '${REMOTE_DIR}/${PROJECT_NAME}' '${backup_path}' + echo '已备份到: ${backup_path}' + else + echo '无旧版本需要备份' + fi + " + log_info "备份完成" +} + +# 上传二进制文件 +upload_binary() { + log_step "上传二进制文件..." + local binary_path="./target/release/${PROJECT_NAME}" + if [ ! -f "$binary_path" ]; then log_error "编译产物未找到: $binary_path" fi - log_info "正在上传二进制文件到 ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP '$binary_path' '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/'" + return 0 + fi + rsync -avzP --progress "$binary_path" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" - log_info "文件上传成功。" + log_info "上传完成" } +# 上传配置文件(只上传 .toml 文件) upload_config() { - log_info "正在上传配置文件到 ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/..." - rsync -avzP --progress ./config/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/" - log_info "配置文件上传成功。" + log_step "上传配置文件..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP --include='*.toml' --exclude='*' ./config/ '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/'" + return 0 + fi + + rsync -avzP --include='*.toml' --exclude='*' ./config/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/" + log_info "配置文件上传完成" } +# 上传测试脚本 upload_test_script() { - log_info "正在上传测试脚本到 ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/..." + log_step "上传测试脚本..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP ./test_deployment.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/'" + return 0 + fi + rsync -avzP --progress ./test_deployment.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/test_deployment.sh" - log_info "测试脚本上传成功。" + log_info "测试脚本上传完成" } -restart_remote_service() { - log_info "正在重启远程服务 '${SERVICE_NAME}'..." +# 重启远程服务 +restart_service() { + log_step "重启服务 '${SERVICE_NAME}'..." + if [ "$DRY_RUN" = true ]; then + log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'systemctl restart ${SERVICE_NAME}'" + return 0 + fi + if ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl restart ${SERVICE_NAME}"; then - log_info "远程服务重启成功。" + log_info "服务重启成功" else - log_error "远程服务重启失败!请检查服务状态和日志。" + log_error "服务重启失败!请检查服务状态和日志。" + fi + + # 等待服务启动 + sleep 3 +} + +# 运行部署后测试 +run_tests() { + log_step "执行部署后测试..." + if [ "$DRY_RUN" = true ]; then + log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh'" + return 0 + fi + + if [ "$SKIP_TESTS" = true ]; then + log_warn "跳过测试(--skip-tests)" + return 0 + fi + + if ssh "${REMOTE_USER}@${REMOTE_HOST}" "TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh"; then + log_info "测试通过" + else + log_error "测试失败!请检查服务状态。" fi } -run_deployment_tests() { - log_info "正在执行部署后测试..." - if ssh "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && ./test_deployment.sh"; then - log_info "部署后测试通过。" - else - log_error "部署后测试失败!请检查服务状态。" +# 确认部署 +confirm_deploy() { + if [ "$SKIP_CONFIRM" = true ]; then + return 0 + fi + + echo "" + echo -e "${YELLOW}========== 部署确认 ==========${NC}" + echo -e " 环境: ${APP_ENV}" + echo -e " 远程目录: ${REMOTE_DIR}" + echo -e " 服务: ${SERVICE_NAME}" + echo -e " Git: ${GIT_VERSION}" + echo -e " 测试域名: ${TEST_DOMAIN}" + echo -e "================================${NC}" + echo "" + + read -p "确认部署? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_warn "部署已取消" + exit 0 fi } +# 显示部署信息 +show_deploy_info() { + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE} Rust Backend 部署脚本${NC}" + echo -e "${BLUE}========================================${NC}" + echo -e " 项目: ${PROJECT_NAME}" + echo -e " 环境: ${APP_ENV}" + echo -e " 远程主机: ${REMOTE_USER}@${REMOTE_HOST}" + echo -e " 远程目录: ${REMOTE_DIR}" + echo -e " 服务: ${SERVICE_NAME}" + echo -e " Git 版本: ${GIT_VERSION}" + if [ "$DRY_RUN" = true ]; then + echo -e " 模式: ${YELLOW}预览模式 (DRY-RUN)${NC}" + fi + echo -e "${BLUE}========================================${NC}" + echo "" +} + +# 清理函数 +cleanup() { + ssh_disconnect +} + +trap cleanup EXIT + +# 主函数 main() { - log_info "========== 开始部署 (环境: ${APP_ENV}) ==========" - log_info "远程目录: ${REMOTE_DIR}" - log_info "服务名称: ${SERVICE_NAME}" + GIT_VERSION=$(get_git_info) + + show_deploy_info + confirm_deploy + check_dependencies + ssh_connect build_project + backup_old_version upload_binary upload_config upload_test_script - restart_remote_service - run_deployment_tests + restart_service + run_tests + + echo "" log_info "========== 部署完成! ==========" + echo -e " Git: ${GIT_VERSION}" + echo -e " 环境: ${APP_ENV}" + echo "" } -main \ No newline at end of file +main diff --git a/test_deployment.sh b/test_deployment.sh index bd5b251..4a9538e 100755 --- a/test_deployment.sh +++ b/test_deployment.sh @@ -2,8 +2,8 @@ set -euo pipefail -DEVELOPMENT_RUST_URL="http://127.0.0.1:8080" -PRODUCTION_DOMAIN="https://xmclassmate.top" +# 从环境变量获取测试域名(deploy.sh 传入) +DOMAIN="${TEST_DOMAIN:-https://xmclassmate.top}" RED='\033[0;31m' GREEN='\033[0;32m' @@ -14,56 +14,58 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } -check_local_backend() { - log_info "测试本地后端服务 (127.0.0.1:8080)..." +check_health() { + local url="${1}/health" + log_info "检查健康端点: ${url}" local status_code - status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${DEVELOPMENT_RUST_URL}/health" 2>/dev/null || echo "000") + status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${url}" 2>/dev/null || echo "000") if [ "$status_code" = "200" ]; then - log_info "本地后端服务正常 (HTTP $status_code)" + log_info "健康检查通过 (HTTP $status_code)" return 0 else - log_error "本地后端服务异常 (HTTP $status_code)" + log_error "健康检查失败 (HTTP $status_code)" return 1 fi } -check_domain_access() { - log_info "测试域名访问 (${PRODUCTION_DOMAIN}/dev)..." +check_api() { + local url="${1}/dev" + log_info "检查 API 端点: ${url}" local status_code - status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L -k \ + status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -k \ -H "Host: xmclassmate.top" \ - "${PRODUCTION_DOMAIN}/dev" 2>/dev/null || echo "000") + "${url}" 2>/dev/null || echo "000") - if [ "$status_code" = "200" ] || [ "$status_code" = "301" ] || [ "$status_code" = "302" ]; then - log_info "域名访问正常 (HTTP $status_code)" + if [ "$status_code" = "200" ] || [ "$status_code" = "401" ] || [ "$status_code" = "301" ] || [ "$status_code" = "302" ]; then + log_info "API 端点正常 (HTTP $status_code)" return 0 else - log_error "域名访问异常 (HTTP $status_code)" + log_error "API 端点异常 (HTTP $status_code)" return 1 fi } check_static_files() { - log_info "测试静态文件服务..." - - local test_files=( + local static_files=( "/static/katex/katex.min.css" "/static/css/style.css" "/static/favicon.ico" ) + log_info "检查静态文件..." + local failed=0 - for file in "${test_files[@]}"; do + for file in "${static_files[@]}"; do local status_code - status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -L -k \ + status_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -k \ -H "Host: xmclassmate.top" \ - "${PRODUCTION_DOMAIN}${file}" 2>/dev/null || echo "000") + "${DOMAIN}${file}" 2>/dev/null || echo "000") if [ "$status_code" = "200" ]; then - log_info "静态文件 ${file} 正常 (HTTP $status_code)" + log_info "静态文件 ${file} 正常" else log_error "静态文件 ${file} 异常 (HTTP $status_code)" failed=1 @@ -76,11 +78,17 @@ check_static_files() { main() { local failed=0 - if ! check_local_backend; then + echo "" + echo -e "${YELLOW}========== 部署后测试 ==========${NC}" + echo -e " 测试域名: ${DOMAIN}" + echo -e "================================${NC}" + echo "" + + if ! check_health "${DOMAIN}"; then failed=1 fi - if ! check_domain_access; then + if ! check_api "${DOMAIN}"; then failed=1 fi @@ -88,6 +96,7 @@ main() { failed=1 fi + echo "" if [ $failed -eq 0 ]; then log_info "========== 所有测试通过 ==========" exit 0 @@ -97,4 +106,4 @@ main() { fi } -main "$@" \ No newline at end of file +main "$@"