refactor: deploy.sh 拆分为模块化结构
- deploy.sh: 从 1574 行精简至 141 行,仅保留主流程编排 - lib/common.sh: 基础设施(日志/SSH复用/备份/上传/迁移/服务管理) - lib/test.sh: 6 项部署后测试函数 - 迁移系统改用 _migrations 追踪表替代文件名模式匹配 - 所有 SSH 操作复用 ControlMaster socket
This commit is contained in:
377
lib/common.sh
Normal file
377
lib/common.sh
Normal file
@@ -0,0 +1,377 @@
|
||||
# ===========================================
|
||||
# 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"; }
|
||||
|
||||
# ---------- 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 ! 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 连接..."
|
||||
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() { ssh -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 <<EOF
|
||||
{"app":"rust-backend","env":"${APP_ENV}","status":"$1","version":"${GIT_VERSION}","message":"$2","timestamp":"$(date -Iseconds)"}
|
||||
EOF
|
||||
)
|
||||
[ "$DRY_RUN" = true ] && { log_dry "curl -X POST '${WEBHOOK_URL}' -d '${payload}'"; return 0; }
|
||||
curl -s -X POST "${WEBHOOK_URL}" -H "Content-Type: application/json" -d "${payload}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ---------- 环境配置 ----------
|
||||
check_env_file() {
|
||||
log_step "检查远程 .env 文件..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "test -f ${REMOTE_DIR}/.env"; return 0; }
|
||||
|
||||
if ! remote "test -f '${REMOTE_DIR}/.env'" 2>/dev/null; then
|
||||
log_error ".env 文件不存在: ${REMOTE_DIR}/.env"; exit 1
|
||||
fi
|
||||
|
||||
local env_content; env_content=$(remote "cat '${REMOTE_DIR}/.env'" 2>/dev/null || echo "")
|
||||
[ -z "$env_content" ] && { log_error ".env 文件为空"; exit 1; }
|
||||
|
||||
local missing=0 ph=0 commented=0
|
||||
local required_vars=("JWT_SECRET" "WECHAT_SECRET" "WECHAT_APPID" "DATABASE_URL")
|
||||
[ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH")
|
||||
|
||||
for var in "${required_vars[@]}"; do
|
||||
local val; val=$(echo "$env_content" | grep -E "^${var}=" | head -1 | sed "s/^${var}=//")
|
||||
if [ -z "$val" ]; then
|
||||
log_error " 缺少: ${var}="; missing=$((missing+1))
|
||||
elif echo "$val" | grep -qiE "^(your_|placeholder|change_me|xxx|test_)"; then
|
||||
log_warn " 占位符: ${var}=${val}"; ph=$((ph+1))
|
||||
fi
|
||||
echo "$env_content" | grep -qE "^#\s*${var}=" && { log_warn " 已注释: # ${var}="; commented=$((commented+1)); }
|
||||
done
|
||||
|
||||
[ $missing -gt 0 ] && { log_error ".env 检查失败:${missing} 个缺失"; exit 1; }
|
||||
[ $ph -gt 0 ] || [ $commented -gt 0 ] && log_warn ".env 存在 ${ph} 个占位符、${commented} 个注释行,建议修正"
|
||||
log_info ".env 文件检查通过"
|
||||
}
|
||||
|
||||
init_env() {
|
||||
log_step "初始化远程 .env 文件..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "在 ${REMOTE_HOST}:${REMOTE_DIR}/.env 生成配置"; return 0; }
|
||||
|
||||
local exists=false
|
||||
remote "test -f '${REMOTE_DIR}/.env'" 2>/dev/null && exists=true
|
||||
|
||||
if [ "$exists" = true ]; then
|
||||
log_warn ".env 已存在,继续将覆盖!"
|
||||
read -p "是否覆盖? [y/N] " -n 1 -r; echo
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; }
|
||||
remote "cp '${REMOTE_DIR}/.env' '${REMOTE_DIR}/.env.backup.$(date +%Y%m%d_%H%M%S)'" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local jwt_secret
|
||||
jwt_secret=$(openssl rand -base64 32 2>/dev/null | tr -d '\n' || echo "change-me-$(date +%s)-$$")
|
||||
|
||||
cat <<ENV_EOF | remote "cat > '${REMOTE_DIR}/.env' && chmod 600 '${REMOTE_DIR}/.env'"
|
||||
# ASD Rust Backend - ${APP_ENV} 环境配置
|
||||
# 由 deploy.sh --init-env 自动生成于 $(date '+%Y-%m-%d %H:%M:%S')
|
||||
APP_ENV=${APP_ENV}
|
||||
DATABASE_URL=请替换为实际数据库连接字符串
|
||||
WECHAT_APPID=请替换为实际微信 AppID
|
||||
WECHAT_SECRET=请替换为实际微信 Secret
|
||||
JWT_SECRET=${jwt_secret}
|
||||
RUST_LOG=info
|
||||
FREE_USER_DATA_LIMIT=20
|
||||
# ===== 支付宝支付配置(可选) =====
|
||||
# ALIPAY_APP_ID=...
|
||||
# ALIPAY_GATEWAY=...
|
||||
ENV_EOF
|
||||
|
||||
log_info ".env 已生成,文件权限 600"
|
||||
log_warn "请手动填写 DATABASE_URL / WECHAT_APPID / WECHAT_SECRET"
|
||||
}
|
||||
|
||||
# ---------- 编译 ----------
|
||||
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 "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
|
||||
else
|
||||
log_warn "数据库备份失败,继续部署"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_db_backups() {
|
||||
local keep="${1:-1}"
|
||||
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 "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 "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}' | docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \
|
||||
remote "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; }
|
||||
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 "tail -${lines} 日志文件"; return 0; }
|
||||
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "")
|
||||
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
|
||||
echo -e "${BLUE}=== ${lf} ===${NC}"; remote "tail -${lines} '${lf}'"
|
||||
}
|
||||
|
||||
tail_logs() {
|
||||
local lines="${LOG_LINES:-100}"
|
||||
log_step "实时跟踪日志..."
|
||||
[ "$DRY_RUN" = true ] && return 0
|
||||
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "")
|
||||
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
|
||||
ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "tail -f -n ${lines} '${lf}'"
|
||||
}
|
||||
|
||||
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"
|
||||
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "")
|
||||
[ -n "$lf" ] && { echo -e "${BLUE}=== 最近日志 ===${NC}"; remote "tail -20 '${lf}'"; }
|
||||
}
|
||||
|
||||
# ---------- 确认 ----------
|
||||
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}"
|
||||
}
|
||||
|
||||
# ---------- 依赖检查 ----------
|
||||
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
|
||||
127
lib/test.sh
Normal file
127
lib/test.sh
Normal file
@@ -0,0 +1,127 @@
|
||||
# ===========================================
|
||||
# test.sh — 部署后测试(依赖 common.sh 的 remote())
|
||||
# ===========================================
|
||||
|
||||
# 测试失败自动回滚
|
||||
rollback_and_exit() {
|
||||
local reason="$1"
|
||||
log_error "测试失败: ${reason}"
|
||||
local last_known_good
|
||||
last_known_good=$(remote "cat ${REMOTE_DIR}/.last_deployed 2>/dev/null || echo ''")
|
||||
if [ -z "$last_known_good" ]; then
|
||||
log_error "无法回滚: 未找到上一个正常版本"; exit 1
|
||||
fi
|
||||
rollback_on_failure "$last_known_good"
|
||||
exit 1
|
||||
}
|
||||
|
||||
test_basic_connectivity() {
|
||||
local max_retries=3 retry=0
|
||||
while [ $retry -lt $max_retries ]; do
|
||||
retry=$((retry+1)); log_info "尝试 ${retry}/${max_retries}: 测试本地后端 (127.0.0.1:${BACKEND_PORT})"
|
||||
local all=true
|
||||
for test in \
|
||||
"/health:200" \
|
||||
"/api/login:400:POST:-H Content-Type:application/json -d '{\"code\":\"test\"}'" \
|
||||
"/api/refresh-token:401:POST:-H Content-Type:application/json -d '{\"refresh_token\":\"bad\"}'" \
|
||||
"/:200"; do
|
||||
IFS=':' read -r path expected method extra <<< "$test"
|
||||
[ -z "$method" ] && method="GET"
|
||||
local code; code=$(remote "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X ${method} http://127.0.0.1:${BACKEND_PORT}${path} ${extra}" 2>/dev/null || echo "000")
|
||||
if [ "$code" != "$expected" ] && [ "$expected" != "200,400" ] && ! { [ "$expected" = "400,200" ] && { [ "$code" = "400" ] || [ "$code" = "200" ]; }; }; then
|
||||
if { [ "$expected" = "400" ] && [ "$code" = "200" ]; } || { [ "$expected" = "401" ] && [ "$code" = "200" ]; }; then
|
||||
log_info " ${path} 正常 (HTTP ${code})"; continue
|
||||
fi
|
||||
log_warn " ${path} 异常 (HTTP ${code})"; all=false
|
||||
else
|
||||
log_info " ${path} 正常 (HTTP ${code})"
|
||||
fi
|
||||
done
|
||||
$all && { log_info "基础连通性测试通过"; return 0; }
|
||||
[ $retry -lt $max_retries ] && sleep 2
|
||||
done
|
||||
log_error "基础连通性测试失败"; return 1
|
||||
}
|
||||
|
||||
test_database_connection() {
|
||||
local r; r=$(remote "docker exec ${DB_CONTAINER} pg_isready -U ${DB_USER} 2>/dev/null" || echo "failed")
|
||||
[[ "$r" =~ "accepting connections" ]] && log_info " PostgreSQL 连接正常" || { log_warn " PostgreSQL 连接失败"; return 1; }
|
||||
|
||||
r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT 1'" 2>/dev/null || echo "failed")
|
||||
[ "$r" = "1" ] && log_info " 数据库查询正常" || { log_warn " 数据库查询失败"; return 1; }
|
||||
|
||||
r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT tablename FROM pg_tables WHERE schemaname='\''public'\'' LIMIT 1'" 2>/dev/null || echo "failed")
|
||||
[ -n "$r" ] && log_info " 表存在: ${r}" || { log_warn " 表查询失败"; return 1; }
|
||||
log_info "数据库连接测试通过"; return 0
|
||||
}
|
||||
|
||||
test_token_flow() {
|
||||
local login; login=$(remote "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"}'" 2>/dev/null || echo "")
|
||||
local token; token=$(echo "$login" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "")
|
||||
[ -z "$token" ] && log_warn " 未获取到 token(微信 code 无效是正常的)" || log_info " 获取到 token"
|
||||
|
||||
# 测试受保护端点
|
||||
local profile; profile=$(remote "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/api/user/profile -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "")
|
||||
echo "$profile" | grep -q '"success":true' && log_info " /api/user/profile JWT 验证成功" || log_warn " /api/user/profile 无权限(正常)"
|
||||
|
||||
local refresh; refresh=$(remote "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "")
|
||||
log_info " refresh_token 端点响应正常"
|
||||
log_info "Token 流程测试通过"; return 0
|
||||
}
|
||||
|
||||
test_weather_details_jwt() {
|
||||
local login; login=$(remote "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"}'" 2>/dev/null || echo "")
|
||||
local token; token=$(echo "$login" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "")
|
||||
[ -z "$token" ] && { log_warn " 无 token,跳过 weather/details 测试(微信 code 无效是正常的)"; return 0; }
|
||||
|
||||
local resp; resp=$(remote "curl -s --max-time 10 'http://127.0.0.1:${BACKEND_PORT}/weather/details?id=1' -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "")
|
||||
if echo "$resp" | grep -q '"success":true' || echo "$resp" | grep -q "Forbidden"; then
|
||||
log_info " weather/details JWT 验证通过"
|
||||
else
|
||||
log_warn " weather/details 响应异常"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
test_invalid_token_401() {
|
||||
local bad="eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.bad"
|
||||
local all=true
|
||||
for path in "/api/user/profile" "/weather/details?id=1" "/api/weather"; do
|
||||
local c; c=$(remote "curl -s -o /dev/null -w '%{http_code}' --max-time 10 'http://127.0.0.1:${BACKEND_PORT}${path}' -H 'Authorization: Bearer ${bad}'" 2>/dev/null || echo "000")
|
||||
[ "$c" = "401" ] && log_info " ${path} 返回 401 ✓" || { log_warn " ${path} 返回 ${c}"; all=false; }
|
||||
done
|
||||
$all && log_info "无效 Token 401 测试通过" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
test_response_content() {
|
||||
local r; r=$(remote "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "")
|
||||
echo "$r" | grep -q '"status"' && echo "$r" | grep -q '"database"' && \
|
||||
log_info " /health JSON 格式正确 ✓" || { log_warn " /health 响应异常"; return 1; }
|
||||
echo "$r" | grep -q '"database":"connected"' && \
|
||||
log_info " 数据库连接状态正常 ✓" || { log_warn " 数据库未连接"; return 1; }
|
||||
log_info "响应内容验证通过"; return 0
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
log_step "执行部署后测试..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "完整测试套件(6 项)"; return 0; }
|
||||
[ "$SKIP_TESTS" = true ] && { log_warn "跳过测试"; return 0; }
|
||||
|
||||
local tests=(
|
||||
"test_basic_connectivity:基础连通性"
|
||||
"test_database_connection:数据库连接"
|
||||
"test_token_flow:Token 流程"
|
||||
"test_weather_details_jwt:weather/details JWT"
|
||||
"test_invalid_token_401:无效 Token 401"
|
||||
"test_response_content:响应内容验证"
|
||||
)
|
||||
local i=1 total=${#tests[@]}
|
||||
for entry in "${tests[@]}"; do
|
||||
IFS=':' read -r fn name <<< "$entry"
|
||||
log_step "测试 ${i}/${total}: ${name}..."
|
||||
$fn || rollback_and_exit "${name} 测试失败"
|
||||
i=$((i+1))
|
||||
done
|
||||
log_info "========== 全部 ${total} 项测试通过 =========="
|
||||
}
|
||||
Reference in New Issue
Block a user