Files
asd-backend/lib/common.sh
milky0217 f9f1ffeff6 refactor: deploy.sh 拆分为模块化结构
- deploy.sh: 从 1574 行精简至 141 行,仅保留主流程编排
- lib/common.sh: 基础设施(日志/SSH复用/备份/上传/迁移/服务管理)
- lib/test.sh: 6 项部署后测试函数
- 迁移系统改用 _migrations 追踪表替代文件名模式匹配
- 所有 SSH 操作复用 ControlMaster socket
2026-05-18 10:09:40 +08:00

378 lines
16 KiB
Bash
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.
# ===========================================
# 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