#!/usr/bin/env bash # ═══════════════════════════════════════════════════════════════════ # 个人资料库 — 一键更新脚本 # 支持镜像来源:Gitea 镜像仓库(拉取)/ 本地源码(重新构建) # 失败时自动回滚至上一版本 # # 用法:bash scripts/update.sh [--yes] [--tag <镜像标签>] # --yes 跳过确认提示 # --tag 指定目标镜像标签(默认: latest) # ═══════════════════════════════════════════════════════════════════ set -euo pipefail IFS=$'\n\t' # ── 脚本自身位置 & 项目根目录 ───────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" STATE_FILE="$PROJECT_DIR/.deploy-state" LOG_FILE="/tmp/resource-library-update-$(date +%Y%m%d%H%M%S).log" # ── 颜色 & 输出 ────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' log() { echo -e "$(date '+%H:%M:%S') $*" | tee -a "$LOG_FILE"; } info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; } ok() { echo -e "${GREEN}[ OK ]${NC} $*" | tee -a "$LOG_FILE"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; } err() { echo -e "${RED}[ERR ]${NC} $*" | tee -a "$LOG_FILE" >&2; } die() { err "$*"; exit 1; } section() { echo -e "\n${BOLD}${BLUE}── $* ─────────────────────────────────────${NC}" | tee -a "$LOG_FILE"; } banner() { cat << 'EOF' ╔═══════════════════════════════════════════╗ ║ 个人资料库 一键更新脚本 ║ ║ Personal Resource Library Updater ║ ╚═══════════════════════════════════════════╝ EOF } # ── 参数解析 ────────────────────────────────────────────────────── AUTO_YES="no" TARGET_TAG="latest" while [ $# -gt 0 ]; do case "$1" in -y|--yes) AUTO_YES="yes" ;; --tag) shift; TARGET_TAG="${1:-latest}" ;; -h|--help) echo "用法: $0 [--yes] [--tag <镜像标签>]" echo " --yes 跳过确认" echo " --tag 指定镜像标签(默认: latest)" exit 0 ;; *) warn "未知参数: $1" ;; esac shift done # ── 工具函数 ────────────────────────────────────────────────────── command_exists() { command -v "$1" &>/dev/null; } confirm() { [ "$AUTO_YES" = "yes" ] && return 0 echo -e "${YELLOW}[?]${NC} $* [y/N]: " read -r ans echo "$ans" | grep -qiE "^y(es)?$" } # ── 读取部署状态 ────────────────────────────────────────────────── load_state() { section "读取部署状态" [ -f "$STATE_FILE" ] || die "未找到 .deploy-state 文件,请先运行 deploy.sh" # shellcheck disable=SC1090 . "$STATE_FILE" # 必须存在的字段 : "${DEPLOY_MODE:?STATE 缺少 DEPLOY_MODE}" : "${COMPOSE_FILE:?STATE 缺少 COMPOSE_FILE}" : "${ENV_FILE:?STATE 缺少 ENV_FILE}" : "${IMAGE_SOURCE:?STATE 缺少 IMAGE_SOURCE}" local env_path="$PROJECT_DIR/$ENV_FILE" [ -f "$env_path" ] || die "env 文件不存在: $env_path" ok "部署模式: $DEPLOY_MODE" ok "镜像来源: $IMAGE_SOURCE" ok "Compose: $COMPOSE_FILE" ok "上次部署: ${DEPLOYED_AT:-未知}" } # ── 检测 Docker Compose 命令 ────────────────────────────────────── detect_compose() { if docker compose version &>/dev/null 2>&1; then COMPOSE_CMD="docker compose" elif command_exists docker-compose; then COMPOSE_CMD="docker-compose" else die "未找到 docker compose / docker-compose,请先运行 deploy.sh" fi ok "Compose: $COMPOSE_CMD" } # ── 获取当前运行镜像的摘要 ──────────────────────────────────────── get_current_digest() { local container_name="${1:-resource_library_app}" docker inspect "$container_name" \ --format='{{index .Config.Image}}:{{.Image}}' 2>/dev/null || echo "unknown" } # ── 拉取最新镜像 ────────────────────────────────────────────────── pull_image() { section "拉取最新镜像" local image="${REGISTRY_IMAGE}:${TARGET_TAG}" info "目标镜像: $image" OLD_DIGEST=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null || echo "none") info "当前本地摘要: ${OLD_DIGEST:0:19}…" info "拉取镜像…" if ! docker pull "$image" 2>&1 | tee -a "$LOG_FILE"; then err "镜像拉取失败" return 1 fi NEW_DIGEST=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null || echo "none") info "最新远端摘要: ${NEW_DIGEST:0:19}…" if [ "$OLD_DIGEST" = "$NEW_DIGEST" ] && [ "$OLD_DIGEST" != "none" ]; then warn "镜像未变更(摘要相同),无需更新" if ! confirm "仍要重启服务吗?"; then ok "跳过更新" exit 0 fi else ok "检测到新版本镜像" fi } # ── 本地重新构建镜像 ────────────────────────────────────────────── build_image() { section "本地重新构建镜像" cd "$PROJECT_DIR" # 尝试拉取最新代码 if command_exists git && [ -d .git ]; then info "拉取最新代码…" local branch branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") if git pull --ff-only 2>&1 | tee -a "$LOG_FILE"; then ok "代码已更新(分支: $branch)" else warn "git pull 失败,使用当前代码构建" fi fi info "重新构建镜像…" docker build -t resource-library:latest . 2>&1 | tee -a "$LOG_FILE" ok "镜像构建完成" } # ── 备份当前容器镜像标签(用于回滚)───────────────────────────── backup_current_image() { section "备份当前镜像" local rollback_tag rollback_tag="rollback-$(date +%Y%m%d%H%M%S)" ROLLBACK_TAG="$rollback_tag" local current_image if [ "$IMAGE_SOURCE" = "registry" ]; then current_image="${REGISTRY_IMAGE}:${TARGET_TAG}" else current_image="resource-library:latest" fi if docker image inspect "$current_image" &>/dev/null; then local target_tag if [ "$IMAGE_SOURCE" = "registry" ]; then target_tag="${REGISTRY_IMAGE}:${rollback_tag}" else target_tag="resource-library:${rollback_tag}" fi docker tag "$current_image" "$target_tag" ok "备份标签: $target_tag" ROLLBACK_IMAGE="$target_tag" else warn "当前镜像不存在,跳过备份(首次更新?)" ROLLBACK_IMAGE="" fi } # ── 滚动更新服务 ────────────────────────────────────────────────── rolling_update() { section "滚动更新服务" cd "$PROJECT_DIR" local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE") [ "${USE_NGINX:-no}" = "yes" ] && compose_args+=(--profile nginx) info "拉起新容器…" $COMPOSE_CMD "${compose_args[@]}" up -d --remove-orphans 2>&1 | tee -a "$LOG_FILE" ok "新容器已启动" } # ── 健康检查 ────────────────────────────────────────────────────── health_check() { section "健康检查" local max_wait=90 elapsed=0 interval=5 local url="http://localhost:${APP_PORT:-5000}/auth/login" info "等待服务就绪(最长 ${max_wait}s)…" while [ "$elapsed" -lt "$max_wait" ]; do if curl -fsSL --max-time 5 "$url" > /dev/null 2>&1; then ok "服务健康!(${elapsed}s)" return 0 fi printf '.' sleep "$interval" elapsed=$((elapsed + interval)) done echo err "健康检查超时" return 1 } # ── 自动回滚 ────────────────────────────────────────────────────── rollback() { section "⚠️ 触发自动回滚" if [ -z "${ROLLBACK_IMAGE:-}" ]; then err "无备份镜像,无法回滚,请手动排查" exit 1 fi warn "正在恢复到备份版本: $ROLLBACK_IMAGE" cd "$PROJECT_DIR" # 将备份镜像还原为目标标签 if [ "$IMAGE_SOURCE" = "registry" ]; then docker tag "$ROLLBACK_IMAGE" "${REGISTRY_IMAGE}:${TARGET_TAG}" else docker tag "$ROLLBACK_IMAGE" "resource-library:latest" fi local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE") [ "${USE_NGINX:-no}" = "yes" ] && compose_args+=(--profile nginx) $COMPOSE_CMD "${compose_args[@]}" up -d 2>&1 | tee -a "$LOG_FILE" # 等待回滚后服务就绪 local rb_wait=60 elapsed=0 while [ "$elapsed" -lt "$rb_wait" ]; do if curl -fsSL --max-time 5 "http://localhost:${APP_PORT:-5000}/auth/login" > /dev/null 2>&1; then ok "回滚成功,服务已恢复(${elapsed}s)" echo err "本次更新失败,已回滚至备份版本: $ROLLBACK_IMAGE" err "更新日志: $LOG_FILE" exit 1 fi sleep 5 elapsed=$((elapsed + 5)) done die "回滚后服务仍不健康,请手动检查: $COMPOSE_CMD ${compose_args[*]} logs" } # ── 清理旧回滚标签(保留最近 3 个)────────────────────────────── cleanup_old_backups() { section "清理旧备份镜像" local prefix if [ "$IMAGE_SOURCE" = "registry" ]; then prefix="${REGISTRY_IMAGE}:rollback-" else prefix="resource-library:rollback-" fi # 列出所有 rollback-* 标签,按时间排序,保留最新 3 个 local tags=() while IFS= read -r tag; do tags+=("$tag") done < <(docker images --format '{{.Repository}}:{{.Tag}}' | grep "^${prefix}" | sort -r) local keep=3 if [ "${#tags[@]}" -gt "$keep" ]; then local to_delete=("${tags[@]:$keep}") for tag in "${to_delete[@]}"; do info "删除旧备份镜像: $tag" docker rmi "$tag" 2>/dev/null || true done ok "已清理 ${#to_delete[@]} 个旧备份" else ok "备份数量未超限(${#tags[@]}/${keep}),无需清理" fi } # ── 打印更新结果 ────────────────────────────────────────────────── print_success() { local local_ip local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || local_ip="<服务器IP>" echo echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo -e "${GREEN}${BOLD} 🚀 更新成功! ${NC}" echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo echo -e " 访问地址: ${CYAN}http://${local_ip}:${APP_PORT:-5000}${NC}" echo if [ -n "${ROLLBACK_IMAGE:-}" ]; then echo -e " 回滚备份: ${YELLOW}$ROLLBACK_IMAGE${NC}" echo -e " 手动回滚: ${BLUE}docker tag $ROLLBACK_IMAGE ${REGISTRY_IMAGE:-resource-library}:latest${NC}" echo -e " ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE up -d${NC}" fi echo echo -e " 查看日志: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE logs -f${NC}" echo -e " 更新日志: $LOG_FILE" echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo } # ── 主流程 ──────────────────────────────────────────────────────── main() { banner log "更新日志: $LOG_FILE" load_state detect_compose # 显示当前状态 section "当前运行状态" $COMPOSE_CMD -f "$COMPOSE_FILE" ps 2>/dev/null | tee -a "$LOG_FILE" || true # 确认更新 confirm "确认执行更新?" || { info "已取消"; exit 0; } backup_current_image # 根据镜像来源更新 if [ "$IMAGE_SOURCE" = "registry" ]; then pull_image || { rollback; } else build_image fi rolling_update if ! health_check; then rollback fi # 更新状态文件中的时间戳 sed -i "s/^DEPLOYED_AT=.*/DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')/" \ "$STATE_FILE" 2>/dev/null || true cleanup_old_backups print_success } main "$@"