# =========================================== # common.sh — deploy.sh 共享基础设施 # =========================================== # ---------- 彩色输出 ---------- 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"; } # 默认超时(秒) : "${DEPLOY_TIMEOUT_SEC:=400}" # ---------- Git 信息 ---------- get_git_info() { local b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") local c=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") echo "${b}@${c}" } # ---------- SSH 连接复用 ---------- get_ssh_socket() { echo "/tmp/ssh-master-${REMOTE_HOST}-$$"; } ssh_connect() { [ "$DRY_RUN" = true ] && { log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) ${REMOTE_USER}@${REMOTE_HOST}"; return 0; } local socket; socket=$(get_ssh_socket) # 测试连接 if ! timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \ -o "ControlMaster=no" "${REMOTE_USER}@${REMOTE_HOST}" "echo ok" &>/dev/null; then log_info "建立 SSH 连接..." timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \ -o ConnectionAttempts=3 -o ConnectTimeout=10 \ -N "${REMOTE_USER}@${REMOTE_HOST}" & sleep 2 fi } ssh_disconnect() { [ "$DRY_RUN" = true ] && return 0 local socket; socket=$(get_ssh_socket) [ -S "${socket}" ] 2>/dev/null || return 0 ssh -o "ControlPath=${socket}" -O exit "${REMOTE_USER}@${REMOTE_HOST}" 2>/dev/null || true } # SSH 快捷执行(复用 ControlMaster) remote() { timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ConnectTimeout=10" -o "ServerAliveInterval=30" -o "ControlPath=$(get_ssh_socket)" "${REMOTE_USER}@${REMOTE_HOST}" "$@"; } # ---------- 部署日志 ---------- get_deploy_log() { echo "${REMOTE_DIR}/deploy.log"; } log_to_file() { [ "$DRY_RUN" = true ] && return 0 remote "echo '[$(date '+%Y-%m-%d %H:%M:%S')] ${APP_ENV} ${GIT_VERSION} $1' >> $(get_deploy_log)" 2>/dev/null || true } # ---------- Webhook ---------- send_webhook() { [ -z "${WEBHOOK_URL:-}" ] && return 0 local payload; payload=$(cat </dev/null 2>&1 || true } # ---------- 环境配置(systemd service)---------- get_service_file_local() { local target="${BG_TARGET:-}" if [ -n "$target" ]; then echo "${SCRIPT_DIR}/deploy/rust-backend-${target}.service" else echo "${SCRIPT_DIR}/deploy/rust-backend-${APP_ENV}.service" fi } check_service_file() { log_step "检查远程 systemd service 文件..." [ "$DRY_RUN" = true ] && { log_dry "检查 ${SERVICE_NAME}"; return 0; } if ! remote "systemctl cat '${SERVICE_NAME}'" &>/dev/null; then log_error "systemd service ${SERVICE_NAME} 不存在,请先用 --deploy-service 安装" exit 1 fi local content; content=$(remote "systemctl cat '${SERVICE_NAME}'" 2>/dev/null || echo "") [ -z "$content" ] && { log_error "service 内容为空"; exit 1; } local missing=0 ph=0 local required_vars=("DATABASE_URL" "JWT_SECRET" "WECHAT_APPID" "WECHAT_SECRET") [ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH") for var in "${required_vars[@]}"; do local val; val=$(echo "$content" | grep -E "^Environment=${var}=" | head -1 | sed "s/^Environment=${var}=//") if [ -z "$val" ]; then log_error " 缺少: Environment=${var}="; missing=$((missing+1)) elif echo "$val" | grep -qiE "^(your_|placeholder|change_me|xxx|test_)"; then log_warn " 占位符: ${var}=${val}"; ph=$((ph+1)) fi done [ $missing -gt 0 ] && { log_error "service 配置检查失败:${missing} 个缺失"; exit 1; } [ $ph -gt 0 ] && log_warn "service 存在 ${ph} 个占位符,部署后可能无法正常运行" log_info "service 配置检查通过" } # ---------- 蓝绿部署:检查目标是否为当前活动环境 ---------- get_proxy_conf() { if [ "${APP_ENV}" = "development" ]; then echo "/etc/nginx/sites-enabled/dev.xmclassmate.top" else echo "/etc/nginx/sites-enabled/xmclassmate.top" fi } check_not_active() { [ -z "$BG_TARGET" ] && return 0 # 非蓝绿部署,跳过 local proxy_conf; proxy_conf=$(get_proxy_conf) local active_env active_env=$(remote "sudo cat ${proxy_conf} 2>/dev/null" 2>/dev/null | grep -oP '127\.0\.0\.1:\d+' | head -1 || echo "") local current_target case "$active_env" in *4433) current_target="blue" ;; *4434) current_target="green" ;; *) current_target="unknown" ;; esac if [ "$current_target" = "$BG_TARGET" ]; then log_error "禁止部署: ${BG_TARGET} (:${BG_PORT}) 是当前活动环境" log_error "请部署到待命环境: $( [ "$BG_TARGET" = "blue" ] && echo 'green' || echo 'blue' )" exit 1 fi log_info "目标 ${BG_TARGET} 非活动环境(当前: ${current_target}),可以部署 ✅" } deploy_service_file() { log_step "部署 systemd service 文件..." [ "$DRY_RUN" = true ] && { log_dry "上传 $(get_service_file_local) → /etc/systemd/system/${SERVICE_NAME}"; return 0; } if [ ! -f "$(get_service_file_local)" ]; then log_error "本地 service 文件不存在: $(get_service_file_local)" log_info "可通过 --init-env 生成模板" exit 1 fi rsync -avzP "$(get_service_file_local)" "${REMOTE_USER}@${REMOTE_HOST}:/etc/systemd/system/${SERVICE_NAME}" # 开发环境自动配置 mock login(带随机密钥) if [ "${APP_ENV}" = "development" ]; then local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0") if [ "$has_mock" = "0" ]; then local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)") local escaped_key; escaped_key=$(echo "$mock_key" | sed 's/[\/&]/\\&/g') remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${escaped_key}' /etc/systemd/system/${SERVICE_NAME}" log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}" fi fi remote "systemctl daemon-reload" log_info "service 文件部署完成,使用 systemctl restart ${SERVICE_NAME} 重启" } init_env() { log_step "初始化 systemd service 文件(从模板生成)..." [ "$DRY_RUN" = true ] && { log_dry "生成 $(get_service_file_local)"; return 0; } if [ -f "$(get_service_file_local)" ]; then log_warn "$(get_service_file_local) 已存在,继续将覆盖!" read -p "是否覆盖? [y/N] " -n 1 -r; echo [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; } cp "$(get_service_file_local)" "$(get_service_file_local).bak.$(date +%Y%m%d_%H%M%S)" fi local jwt_secret jwt_secret=$(openssl rand -base64 32 2>/dev/null | tr -d '\n' || echo "change-me-$(date +%s)-$$") mkdir -p "${SCRIPT_DIR}/deploy" cat > "$(get_service_file_local)" << SERVEOF [Unit] Description=Rust Backend ${APP_ENV^} Service After=network.target postgresql.service Wants=network.target [Service] Type=simple User=rust-backend Group=rust-backend WorkingDirectory=${REMOTE_DIR} ExecStart=${REMOTE_DIR}/rust-backend Restart=always RestartSec=5 StandardOutput=journal StandardError=journal Environment=DATABASE_URL=请替换为实际数据库连接 Environment=WECHAT_APPID=请替换为实际微信 AppID Environment=WECHAT_SECRET=请替换为实际微信 Secret Environment=JWT_SECRET=${jwt_secret} Environment=SSL_KEY_PATH= Environment=SSL_CERT_PATH= Environment=RUST_LOG=info Environment=APP_VERSION=0.3.0 Environment=FREE_USER_DATA_LIMIT=20 # 支付宝密钥存储在 .env 文件(不在 Git 中) EnvironmentFile=${REMOTE_DIR}/.env SERVEOF chmod 600 "$(get_service_file_local)" log_info "service 模板已生成: $(get_service_file_local)" log_warn "请手动填写 DATABASE_URL / WECHAT_APPID / WECHAT_SECRET" log_info "然后使用 ./deploy.sh ${APP_ENV} --deploy-service 上传" } # ---------- 编译 ---------- build_project() { log_step "编译项目 (release 模式)..." [ "$DRY_RUN" = true ] && { log_dry "cargo build --release"; return 0; } APP_ENV="${APP_ENV}" cargo build --release || { log_error "编译失败!"; exit 1; } log_info "编译成功" } # ---------- 备份 ---------- backup_old_version() { log_step "备份旧版本..." local ts; ts=$(date +%Y%m%d_%H%M%S) local gh; gh=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") local md5="" [ "$DRY_RUN" = false ] && md5=$(remote "md5sum '${REMOTE_DIR}/${PROJECT_NAME}' 2>/dev/null | cut -d' ' -f1" || echo "") BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${ts}.${gh}.${md5:0:8}" [ "$DRY_RUN" = true ] && { log_dry "备份到 ${BACKUP_PATH}"; return 0; } remote "if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}'; echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed'; fi" log_info "备份完成" } cleanup_old_backups() { local keep="${1:-5}" [ "$DRY_RUN" = true ] && { log_dry "保留最近 ${keep} 个备份"; return 0; } local backups; backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") [ -z "$backups" ] && return 0 local seen="" to_del="" while IFS= read -r b; do [ -z "$b" ] && continue local md5; md5=$(basename "$b" | rev | cut -d'.' -f2 | rev || echo "") [ -z "$md5" ] && { to_del="${to_del}${b}"$'\n'; continue; } echo "$seen" | grep -q "^${md5}$" && to_del="${to_del}${b}"$'\n' || seen="${seen}${md5}"$'\n' done <<< "$backups" # 如果唯一版本数超过 keep,按时间删除最旧的溢出部分 local count=0 while IFS= read -r b; do [ -z "$b" ] && continue; count=$((count+1)) [ "$count" -gt "$keep" ] && to_del="${to_del}${b}"$'\n' done <<< "$backups" [ -z "$to_del" ] && return 0 log_step "清理旧备份(保留 ${keep} 个版本)..." echo "$to_del" | while IFS= read -r f; do [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true done } backup_database() { log_step "备份数据库..." [ "$DRY_RUN" = true ] && { log_dry "pg_dump → ${REMOTE_DIR}/backups/"; return 0; } local ts; ts=$(date +%Y%m%d_%H%M%S) local bf="${REMOTE_DIR}/backups/${DB_NAME}_${ts}.dump" remote "mkdir -p ${REMOTE_DIR}/backups" if remote "sudo docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${ts}.dump && mv /tmp/backup_${ts}.dump '${bf}'" 2>&1; then log_info "数据库备份成功: ${DB_NAME}_${ts}.dump" cleanup_old_db_backups 7 else log_warn "数据库备份失败,继续部署" fi } cleanup_old_db_backups() { local keep="${1:-7}" local to_del; to_del=$(remote "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump* 2>/dev/null | tail -n +$((keep+1))" || echo "") [ -z "$to_del" ] && return 0 log_step "清理旧数据库备份(保留 ${keep} 个)..." echo "$to_del" | while read -r f; do [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true; done } list_backups() { log_step "可用备份列表..." [ "$DRY_RUN" = true ] && { log_dry "ls ${REMOTE_DIR}/${PROJECT_NAME}.backup.*"; return 0; } remote "ls -la ${REMOTE_DIR}/${PROJECT_NAME}.backup.*" 2>/dev/null || echo "无备份文件" } # ---------- 上传 ---------- upload_binary() { log_step "上传二进制文件..." local src="./target/release/${PROJECT_NAME}" [ ! -f "$src" ] && { log_error "编译产物不存在: $src"; exit 1; } [ "$DRY_RUN" = true ] && { log_dry "rsync ${src} → ${REMOTE_DIR}/"; return 0; } rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" log_info "上传完成" } upload_dir() { local label="$1" src="$2" dst="${3:-$2}" log_step "上传 ${label}..." [ "$DRY_RUN" = true ] && { log_dry "rsync ${src}/ → ${REMOTE_DIR}/${dst}/"; return 0; } [ ! -d "$src" ] && { log_warn "${src} 目录不存在,跳过"; return 0; } rsync -avzP --progress "${src}/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}/" log_info "${label} 上传完成" } upload_script() { local label="$1" src="$2" dst="$3" log_step "上传 ${label}..." [ "$DRY_RUN" = true ] && { log_dry "rsync ${src} → ${REMOTE_DIR}/${dst}"; return 0; } [ ! -f "$src" ] && { log_warn "${src} 不存在,跳过"; return 0; } rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}" remote "chmod +x ${REMOTE_DIR}/${dst}" 2>/dev/null || true log_info "${label} 上传完成" } # ---------- 迁移 ---------- run_migrations() { log_step "执行数据库迁移..." [ "$DRY_RUN" = true ] && { local n; n=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo 0); log_dry "发现 ${n} 个迁移文件"; return 0; } # 先备份再执行 local sql_files; sql_files=$(remote "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") [ -n "$sql_files" ] && backup_database # 创建迁移追踪表 remote "sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" <<<'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW());' 2>/dev/null || true for f in $sql_files; do local name; name=$(basename "$f") log_info "检查迁移: ${name}" local done; done=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM _migrations WHERE name='${name}'\"" 2>/dev/null || echo "0") [ "$done" != "0" ] && { log_info " 跳过(已执行): ${name}"; continue; } log_info " 执行: ${name}" remote "cat '${f}' | sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \ remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"INSERT INTO _migrations (name) VALUES ('${name}')\"" && \ log_info " 完成: ${name}" || log_warn " 迁移 ${name} 可能已执行或失败" done log_info "数据库迁移完成" } # ---------- 服务 ---------- restart_service() { log_step "重启服务 '${SERVICE_NAME}'..." [ "$DRY_RUN" = true ] && { log_dry "systemctl restart ${SERVICE_NAME}"; return 0; } # 部署前先确保 service 文件存在 if ! remote "systemctl cat '${SERVICE_NAME}'" &>/dev/null; then log_warn "${SERVICE_NAME} 未安装,尝试部署 service 文件..." deploy_service_file fi remote "systemctl restart ${SERVICE_NAME}" && log_info "服务重启成功" || log_error "服务重启失败!" sleep 3 } # ---------- 回滚 ---------- rollback() { log_step "查找可用备份..." [ "$DRY_RUN" = true ] && { log_dry "回滚预览"; return 0; } local backups backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") [ -z "$backups" ] && { log_error "没有可用备份"; return 1; } echo ""; echo -e "${BLUE}可用备份:${NC}"; echo "$backups" | head -10 | nl -v 0 local count; count=$(echo "$backups" | wc -l) read -p "选择备份 [0-$(($count>10?9:$count-1)),默认 0]: " -n 1 -r; echo [[ ! $REPLY =~ ^[0-9]$ ]] && REPLY=0 local chosen; chosen=$(echo "$backups" | sed -n "$((REPLY+1))p") echo -e "${YELLOW}回滚到: ${chosen}${NC}" [ "$SKIP_CONFIRM" = false ] && { read -p "确认? [y/N] " -n 1 -r; echo; [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; } } remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true remote "cp '${chosen}' '${REMOTE_DIR}/${PROJECT_NAME}'" remote "systemctl start ${SERVICE_NAME}" log_info "回滚完成" log_to_file "MANUAL_ROLLBACK to $(basename ${chosen})" send_webhook "rollback" "手动回滚: $(basename ${chosen})" } rollback_on_failure() { local bp="$1"; [ -z "$bp" ] && { log_error "未指定备份路径"; return 1; } log_error "========== 测试失败,自动回滚 ==========" [ "$DRY_RUN" = true ] && { log_dry "回滚到 ${bp}"; return 0; } remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true remote "cp '${bp}' '${REMOTE_DIR}/${PROJECT_NAME}'" remote "systemctl start ${SERVICE_NAME}"; sleep 3 log_info "自动回滚完成" log_to_file "AUTO_ROLLBACK to $(basename ${bp})" send_webhook "failure" "部署失败,自动回滚: $(basename ${bp})" } # ---------- 日志查看 ---------- view_logs() { local lines="${LOG_LINES:-50}" log_step "查看后端日志(最后 ${lines} 行)..." [ "$DRY_RUN" = true ] && { log_dry "journalctl -u ${SERVICE_NAME} -n ${lines}"; return 0; } remote "journalctl -u ${SERVICE_NAME} -n ${lines} --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志" } tail_logs() { local lines="${LOG_LINES:-100}" log_step "实时跟踪日志..." [ "$DRY_RUN" = true ] && return 0 ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "journalctl -u ${SERVICE_NAME} -n ${lines} -f" } status() { log_step "服务状态..." [ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; } echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}" remote "systemctl status ${SERVICE_NAME} --no-pager" echo -e "${BLUE}=== 最近日志 ===${NC}" remote "journalctl -u ${SERVICE_NAME} -n 20 --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志" } # ---------- 确认 ---------- confirm_deploy() { [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ] && return 0 echo ""; echo -e "${YELLOW}========== 部署确认 ==========${NC}" echo -e " 环境: ${APP_ENV}" echo -e " 远程目录: ${REMOTE_DIR}" echo -e " 服务: ${SERVICE_NAME}" echo -e " Git: ${GIT_VERSION}" echo -e "================================${NC}" read -p "确认部署? [y/N] " -n 1 -r; echo [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; exit 0; } } 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}" [ "$DRY_RUN" = true ] && echo -e " 模式: ${YELLOW}预览 (DRY-RUN)${NC}" echo -e "${BLUE}========================================${NC}" } # ---------- SSL 证书检查 ---------- check_ssl_expiry() { [ "${APP_ENV}" != "production" ] && return 0 # 非生产环境跳过 log_step "检查 SSL 证书到期时间..." [ "$DRY_RUN" = true ] && { log_dry "openssl x509 -checkend 2592000"; return 0; } # 读取 SSL 证书 local cert_info; cert_info=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate -checkend 2592000 2>/dev/null" || echo "") if echo "$cert_info" | grep -q "Certificate will expire"; then local expiry; expiry=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2" || echo "未知") local days_left; days_left=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -checkend 86400 2>/dev/null | grep -c 'will expire'" || echo "0") log_warn "SSL 证书将于 ${expiry} 到期(不足 30 天)!" [ "$days_left" -gt "0" ] && log_warn " 警告:证书将在 1 天内到期,请立即续期!" else log_info "SSL 证书有效期超过 30 天 ✅" fi } # ---------- 依赖检查 ---------- check_dependencies() { log_step "检查依赖..." for cmd in cargo rsync ssh; do command -v "$cmd" &>/dev/null || { log_error "依赖 '$cmd' 未安装"; exit 1; } done log_info "依赖检查通过" } # ---------- 清理 ---------- cleanup() { ssh_disconnect 2>/dev/null || true; } trap cleanup EXIT