同步工作区更新:优化安装脚本与更新脚本,添加文档与测试脚本

This commit is contained in:
Cheng Zhou
2026-06-23 17:18:34 +08:00
parent efc325568d
commit 4bbc234b53
8 changed files with 1998 additions and 19 deletions
+546 -10
View File
@@ -5,36 +5,572 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/common.sh"
# ── 全局变量 ─────────────────────────────────
TARGET_ENV=""
REPO_URL=""
GIT_BRANCH=""
ASSUME_YES=0
SKIP_BACKUP=0
FORCE_PULL=0
INSTALL_ARGS=()
# 项目身份验证标识文件(至少需要其中 2 个)
REQUIRED_PROJECT_FILES=(
"scripts/install.sh"
"docker-compose.yaml"
"backend/app/main.py"
"README.md"
)
# ── Banner ───────────────────────────────────
_banner() {
local b="${CC_PRIMARY}${C_BOLD}"
local a="${CC_INFO}"
local r="${C_RESET}"
printf '\n'
printf '%s╔══════════════════════════════════════════════╗%s\n' "$b" "$r"
printf '%s║%s ██╗ ██╗██████╗ ██████╗ █████╗ ████████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██████╔╝██║ ██║███████║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║██╔═══╝ ██║ ██║██╔══██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚██████╔╝██║ ██████╔╝██║ ██║ ██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s 临床试验管理系统 · 在线更新程序 %s║%s\n' "$b" "${CC_MUTED}" "${CC_PRIMARY}" "$r"
printf '%s╚══════════════════════════════════════════════╝%s\n' "$b" "$r"
printf '\n'
}
# ── 帮助 ─────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET}
bash scripts/update.sh <环境> [安装脚本选项]
bash scripts/update.sh ${C_CYAN}<环境>${C_RESET} [选项]
${C_BOLD}环境:${C_RESET}
dev | main | release
${C_CYAN}dev${C_RESET} 本地开发环境
${C_CYAN}main${C_RESET} 内网测试 / 预发布环境
${C_CYAN}release${C_RESET} 生产环境
${C_BOLD}选项:${C_RESET}
${C_WHITE}--repo-url <url>${C_RESET} Git 仓库地址(HTTPS 或 SSH
不指定时会提示输入或使用当前远程地址
${C_WHITE}--branch <name>${C_RESET} 指定拉取的分支(默认为当前分支)
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-backup${C_RESET} 跳过更新前自动备份 tag(不推荐)
${C_WHITE}--force${C_RESET} 强制覆盖本地更改(git reset --hard
${C_WHITE}-h, --help${C_RESET} 显示此帮助信息
${C_BOLD}认证方式:${C_RESET}
${C_WHITE}1. SSH 密钥${C_RESET} 使用 SSH URL(如已配置密钥,自动使用)
${C_WHITE}2. Personal Token${C_RESET} 推荐使用 GitHub/Gitea Personal Access Token
${C_WHITE}3. 用户名密码${C_RESET} HTTPS 基础认证(不推荐,安全性较低)
可通过环境变量提供凭证(适合自动化):
GIT_USERNAME Git 用户名
GIT_PASSWORD Git 密码或 Personal Access Token
${C_BOLD}示例:${C_RESET}
# 交互式更新(会提示输入仓库地址)
bash scripts/update.sh dev
# 指定仓库地址更新
bash scripts/update.sh main --repo-url https://github.com/user/ctms-dev.git
# 全自动模式(从环境变量读取凭证)
export GIT_USERNAME="user"
export GIT_PASSWORD="ghp_xxxxxxxxxxxxx"
bash scripts/update.sh release --yes --repo-url https://github.com/user/ctms-dev.git
# 强制覆盖本地更改
bash scripts/update.sh dev --force
${C_BOLD}说明:${C_RESET}
更新流程复用 scripts/install.sh:校验配置、重建/刷新容器、执行迁移并健康检查。
本脚本不会执行 git pull,请先确认代码已处于目标版本。
更新流程:检查工作目录 → 验证仓库身份 → 拉取代码 → 调用安装脚本部署
默认会在拉取前创建备份 tag(格式:pre-update-YYYYMMDD-HHMMSS
EOF
}
main() {
# ── 参数解析 ─────────────────────────────────
parse_args() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
local env="$1"
TARGET_ENV="$1"
shift
ctms_require_env "$env"
case "$TARGET_ENV" in
dev|main|release) ;;
-h|--help) usage; exit 0 ;;
*)
usage
ctms_fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release"
;;
esac
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-url)
[[ $# -ge 2 ]] || ctms_fail "--repo-url 需要提供一个 URL 参数"
REPO_URL="$2"
shift 2
;;
--branch)
[[ $# -ge 2 ]] || ctms_fail "--branch 需要提供分支名"
GIT_BRANCH="$2"
shift 2
;;
--yes) ASSUME_YES=1; shift ;;
--skip-backup) SKIP_BACKUP=1; shift ;;
--force) FORCE_PULL=1; shift ;;
-h|--help) usage; exit 0 ;;
--skip-build|--skip-migrate|--verbose|--base-url)
# 透传给 install.sh 的参数
INSTALL_ARGS+=("$1")
if [[ "$1" == "--base-url" && $# -ge 2 ]]; then
INSTALL_ARGS+=("$2")
shift
fi
shift
;;
*)
usage
ctms_fail "未知参数: $1"
;;
esac
done
}
# ── Git 工具函数 ─────────────────────────────
check_git_installed() {
command -v git >/dev/null 2>&1 || ctms_fail "未检测到 git 命令,请先安装 Git"
}
check_is_git_repo() {
git rev-parse --git-dir >/dev/null 2>&1 || ctms_fail "当前目录不是 Git 仓库"
}
get_current_branch() {
git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD"
}
get_current_remote_url() {
local branch="${1:-$(get_current_branch)}"
local remote
remote="$(git config "branch.$branch.remote" 2>/dev/null || echo "origin")"
git config "remote.$remote.url" 2>/dev/null || echo ""
}
has_uncommitted_changes() {
! git diff-index --quiet HEAD -- 2>/dev/null
}
has_untracked_files() {
[[ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]]
}
# ── 项目身份验证 ─────────────────────────────
verify_project_identity() {
ctms_step "验证项目身份"
local found=0
local file
for file in "${REQUIRED_PROJECT_FILES[@]}"; do
if [[ -f "$CTMS_ROOT_DIR/$file" ]]; then
found=$((found + 1))
fi
done
if [[ $found -lt 2 ]]; then
ctms_fail "项目身份验证失败:未检测到足够的 CTMS 项目特征文件\n" \
" 需要至少 2 个特征文件,当前仅找到 $found 个\n" \
" 请确认仓库地址是否正确"
fi
# 额外检查 README 内容
if [[ -f "$CTMS_ROOT_DIR/README.md" ]]; then
if ! grep -q "CTMS" "$CTMS_ROOT_DIR/README.md" 2>/dev/null; then
ctms_warn "README.md 不包含 CTMS 关键字,可能拉取了错误的仓库"
fi
fi
ctms_ok "项目身份验证通过(检测到 $found 个特征文件)"
}
# ── 工作目录状态检查 ─────────────────────────
check_working_directory() {
ctms_step "检查工作目录状态"
if [[ "$FORCE_PULL" -eq 1 ]]; then
ctms_warn "已启用 --force,将强制覆盖本地更改"
return 0
fi
local has_changes=0
if has_uncommitted_changes; then
ctms_warn "检测到未提交的更改"
has_changes=1
fi
if has_untracked_files; then
ctms_warn "检测到未跟踪的文件"
has_changes=1
fi
if [[ $has_changes -eq 0 ]]; then
ctms_ok "工作目录干净"
return 0
fi
if [[ "$ASSUME_YES" -eq 1 ]]; then
ctms_fail "检测到未提交的更改且已启用 --yes 模式\n" \
" 请先提交或使用 --force 参数强制覆盖"
fi
printf '\n %s%s%s 选择处理方式:%s\n' "${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_RESET}"
printf ' %s1)%s 暂存更改(git stash\n' "${C_BOLD}" "${C_RESET}"
printf ' %s2)%s 放弃更改(git reset --hard\n' "${C_BOLD}" "${C_RESET}"
printf ' %s3)%s 取消更新\n' "${C_BOLD}" "${C_RESET}"
printf ' 请输入选项 [1-3]: '
local choice
read -r choice || choice=""
case "$choice" in
1)
ctms_info "暂存本地更改..."
git stash push -m "auto-stash before update $(date +%Y%m%d-%H%M%S)" || ctms_fail "暂存失败"
ctms_ok "已暂存本地更改(可使用 'git stash pop' 恢复)"
;;
2)
ctms_warn "即将放弃所有本地更改..."
printf ' 确认输入 %syes%s 继续: ' "${C_BOLD}" "${C_RESET}"
local confirm
read -r confirm || confirm=""
[[ "$confirm" == "yes" ]] || ctms_fail "用户取消操作"
git reset --hard HEAD || ctms_fail "重置失败"
git clean -fd || true
ctms_ok "已放弃本地更改"
;;
3|*)
ctms_fail "用户取消更新"
;;
esac
}
# ── 仓库地址解析 ─────────────────────────────
resolve_repo_url() {
ctms_step "解析仓库地址"
# 如果未指定,尝试从当前 remote 获取
if [[ -z "$REPO_URL" ]]; then
REPO_URL="$(get_current_remote_url)"
if [[ -n "$REPO_URL" ]]; then
ctms_info "检测到当前远程地址: $REPO_URL"
fi
fi
# 如果仍为空且非交互模式,报错
if [[ -z "$REPO_URL" && "$ASSUME_YES" -eq 1 ]]; then
ctms_fail "未指定仓库地址且无法从当前 remote 获取\n" \
" 请使用 --repo-url 参数指定"
fi
# 交互式输入
if [[ -z "$REPO_URL" ]]; then
printf ' %s▸%s 请输入 Git 仓库地址: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r REPO_URL || true
fi
[[ -n "$REPO_URL" ]] || ctms_fail "仓库地址不能为空"
# 验证 URL 格式
if [[ ! "$REPO_URL" =~ ^(https?://|git@|ssh://) ]]; then
ctms_fail "仓库地址格式不正确\n" \
" 支持格式:https://..., git@..., ssh://..."
fi
ctms_ok "仓库地址: $REPO_URL"
}
# ── 远程引用选择 ─────────────────────────────
pick_remote_ref() {
# --branch 已指定则跳过
if [[ -n "$GIT_BRANCH" ]]; then
ctms_info "指定引用: $GIT_BRANCH"
return
fi
# --yes 模式无法交互,fallback 到当前分支
if [[ "$ASSUME_YES" -eq 1 ]]; then
GIT_BRANCH="$(get_current_branch)"
ctms_info "自动模式,使用当前分支: $GIT_BRANCH"
return
fi
ctms_step "选择远程引用"
local raw
raw="$(git ls-remote --refs "$REPO_URL" 2>/dev/null)" || {
GIT_BRANCH="$(ctms_ask_line "无法列出远程引用,请手动输入分支/标签/SHA" "$(get_current_branch)")"
return
}
local -a names types
local sha ref name
while IFS=$'\t' read -r sha ref; do
name="${ref#refs/heads/}"
names+=("$name"); types+=("branch")
done < <(printf '%s\n' "$raw" | grep $'\trefs/heads/')
while IFS=$'\t' read -r sha ref; do
name="${ref#refs/tags/}"
names+=("$name"); types+=("tag")
done < <(printf '%s\n' "$raw" | grep $'\trefs/tags/' | sort -rt/ -k3 | head -10)
local manual_idx=$(( ${#names[@]} + 1 ))
local choice i
while true; do
printf '\n'
for i in "${!names[@]}"; do
printf ' %s%2d)%s %s%-8s%s %s\n' \
"${C_BOLD}" $((i+1)) "${C_RESET}" \
"${CC_MUTED}" "${types[$i]}" "${C_RESET}" "${names[$i]}"
done
printf ' %s%2d)%s %s手动输入 SHA / 分支名 / 标签名%s\n\n' \
"${C_BOLD}" "$manual_idx" "${C_RESET}" "${CC_MUTED}" "${C_RESET}"
printf ' %s▸%s 请选择 (1-%d): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$manual_idx"
read -r choice || choice=""
if [[ "$choice" == "$manual_idx" || -z "$choice" ]]; then
GIT_BRANCH="$(ctms_ask_line "请输入分支名、标签或 SHA")"
break
elif [[ "$choice" =~ ^[0-9]+$ && "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]]; then
GIT_BRANCH="${names[$((choice-1))]}"
ctms_ok "已选择 ${types[$((choice-1))]}: $GIT_BRANCH"
break
else
ctms_warn "无效选择,请重新输入"
fi
done
}
# ── 认证处理 ─────────────────────────────────
setup_git_credentials() {
# SSH 协议不需要额外配置
if [[ "$REPO_URL" =~ ^(git@|ssh://) ]]; then
ctms_info "使用 SSH 协议,跳过凭证配置"
return 0
fi
ctms_step "配置 Git 认证"
# 从环境变量读取凭证
local username="${GIT_USERNAME:-}"
local password="${GIT_PASSWORD:-}"
# 如果环境变量未设置且非自动模式,交互式输入
if [[ -z "$username" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s Git 用户名: ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r username || username=""
fi
if [[ -z "$password" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s Git 密码/Token (输入不回显): ' "${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -rs password || password=""
printf '\n'
fi
# 如果凭证为空,尝试使用系统凭证存储
if [[ -z "$username" || -z "$password" ]]; then
ctms_info "未提供凭证,将尝试使用系统凭证存储"
return 0
fi
# 配置临时凭证助手(仅本次会话有效)
export GIT_ASKPASS_USERNAME="$username"
export GIT_ASKPASS_PASSWORD="$password"
# 创建临时凭证助手脚本
local askpass_script="/tmp/git-askpass-$$.sh"
cat > "$askpass_script" <<'ASKPASS'
#!/bin/bash
case "$1" in
*Username*) echo "$GIT_ASKPASS_USERNAME" ;;
*Password*) echo "$GIT_ASKPASS_PASSWORD" ;;
esac
ASKPASS
chmod +x "$askpass_script"
export GIT_ASKPASS="$askpass_script"
ctms_ok "认证配置完成"
}
cleanup_git_credentials() {
if [[ -n "${GIT_ASKPASS:-}" && -f "$GIT_ASKPASS" ]]; then
rm -f "$GIT_ASKPASS" 2>/dev/null || true
fi
unset GIT_ASKPASS GIT_ASKPASS_USERNAME GIT_ASKPASS_PASSWORD
}
# ── 备份当前版本 ─────────────────────────────
create_backup_tag() {
if [[ "$SKIP_BACKUP" -eq 1 ]]; then
ctms_info "已跳过备份(--skip-backup"
return 0
fi
ctms_step "创建更新前备份"
local backup_tag="pre-update-$(date +%Y%m%d-%H%M%S)"
if git tag "$backup_tag" 2>/dev/null; then
ctms_ok "已创建备份 tag: $backup_tag"
ctms_info "如需回滚,执行: git reset --hard $backup_tag"
else
ctms_warn "备份 tag 创建失败(可能当前无提交)"
fi
}
# ── 拉取代码 ─────────────────────────────────
fetch_and_pull() {
ctms_step "拉取远程代码"
local remote="origin"
# 更新或添加 remote
if git remote | grep -q "^${remote}$"; then
git remote set-url "$remote" "$REPO_URL" || ctms_fail "更新远程地址失败"
else
git remote add "$remote" "$REPO_URL" || ctms_fail "添加远程仓库失败"
fi
# Fetch(含 tags
ctms_info "正在从远程仓库获取更新..."
if ! git fetch "$remote" --tags 2>&1 | while IFS= read -r line; do
printf ' %s\n' "$line"
done; then
cleanup_git_credentials
ctms_fail "拉取失败,请检查网络连接和仓库权限"
fi
# 支持分支、标签、SHA — 统一 reset --hard
if git rev-parse --verify "$remote/$GIT_BRANCH" >/dev/null 2>&1; then
ctms_info "切换到远程分支: $GIT_BRANCH"
git reset --hard "$remote/$GIT_BRANCH" || ctms_fail "重置失败"
elif git rev-parse --verify "$GIT_BRANCH" >/dev/null 2>&1; then
ctms_info "切换到引用: $GIT_BRANCH"
git reset --hard "$GIT_BRANCH" || ctms_fail "重置失败"
else
cleanup_git_credentials
ctms_fail "无法解析引用 '$GIT_BRANCH',请确认分支/标签/SHA 正确"
fi
cleanup_git_credentials
ctms_ok "代码拉取完成"
}
# ── 更新确认 ─────────────────────────────────
confirm_update() {
if [[ "$ASSUME_YES" -eq 1 ]]; then
ctms_info "已启用 --yes,跳过确认"
return 0
fi
ctms_step "确认更新参数"
local w=66
local row
row() {
local label="$1" value="$2" vcolor="${3:-$CC_TEXT}"
local padded
padded="$(ctms_pad_right "$label" 14)"
ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$w" \
"${padded}${value}" \
"${CC_MUTED}${padded}${C_RESET}${vcolor}${value}${C_RESET}"
}
printf '\n'
ctms_box_top "${CC_PRIMARY}${C_BOLD}" "更新参数预览" "$w"
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "仓库地址" "$REPO_URL" "${CC_INFO}"
row "目标引用" "$GIT_BRANCH" "${CC_TEXT}"
row "本地更改" "$([[ "$FORCE_PULL" -eq 1 ]] && echo "强制跳过检查" || echo "检测并确认")" "${CC_TEXT}"
row "备份策略" "$([[ "$SKIP_BACKUP" -eq 1 ]] && echo "跳过" || echo "自动创建 tag")" "${CC_TEXT}"
ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w"
printf '\n %s%s%s 确认以上参数并继续更新?输入 %syes%s 继续: ' \
"${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
local answer
read -r answer || answer=""
[[ "$answer" == "yes" ]] || ctms_fail "用户取消更新"
}
# ── 调用安装脚本 ─────────────────────────────
run_install_script() {
ctms_step "执行部署更新"
local install_script="$CTMS_ROOT_DIR/scripts/install.sh"
[[ -x "$install_script" ]] || ctms_fail "安装脚本不可执行: $install_script"
ctms_info "更新会复用现有安装流程:校验配置、重建容器、执行迁移并健康检查。"
ctms_info "该命令不会执行 git pull;请先确认代码已处于目标版本。"
"$install_script" "$env" "$@"
ctms_info "调用安装脚本完成容器更新..."
"$install_script" "$TARGET_ENV" "${INSTALL_ARGS[@]}"
}
# ── 完成提示 ─────────────────────────────────
show_success() {
local w=66
local current_commit
current_commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
printf '\n'
ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 更新成功" "$w"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"$(ctms_pad_right "当前版本" 14)${current_commit}" \
"${CC_MUTED}$(ctms_pad_right "当前版本" 14)${C_RESET}${CC_INFO}${current_commit}${C_RESET}"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"$(ctms_pad_right "环境" 14)${TARGET_ENV}" \
"${CC_MUTED}$(ctms_pad_right "环境" 14)${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}"
ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w"
printf '\n'
}
# ── 入口 ─────────────────────────────────────
main() {
_banner
parse_args "$@"
cd "$CTMS_ROOT_DIR"
check_git_installed
check_is_git_repo
check_working_directory
resolve_repo_url
setup_git_credentials
pick_remote_ref
confirm_update
# 先验证当前项目身份
verify_project_identity
# 创建备份
create_backup_tag
# 拉取代码
fetch_and_pull
# 拉取后再次验证(防止拉错仓库)
verify_project_identity
# 调用安装脚本完成部署
run_install_script
show_success
}
# 捕获退出信号,清理凭证
trap cleanup_git_credentials EXIT
main "$@"