Files
ai-app-database/scripts/update.sh
huty ac8c49edc7
Some checks are pending
CI — Docker Build & Push / Build & Push Image (push) Waiting to run
feat: 添加 Linux 一键部署和更新脚本
scripts/deploy.sh — 一键部署:
  - 自动检测发行版(Ubuntu/Debian/CentOS/RHEL/Rocky/AlmaLinux/
    Fedora/Alpine/Arch/Manjaro/openSUSE)并安装 Docker + Compose
  - 兼容 Docker Compose v1(docker-compose)和 v2(docker compose)
  - 支持两种部署模式:新建 MySQL / 现有 MySQL
  - 支持镜像来源:Gitea 仓库拉取 / 本地构建
  - 交互式配置:端口、密钥、管理员账号、MySQL 密码等
  - 自动生成加密随机 SECRET_KEY
  - 可选启用 Nginx 反向代理(--profile nginx)
  - 启动后执行健康检查,访问 /auth/login 验证

scripts/update.sh — 一键更新:
  - 读取 .deploy-state 恢复上次部署配置,无需重新输入参数
  - 更新前自动备份当前镜像标签(rollback-<时间戳>)
  - 拉取新镜像时对比摘要,无变化时提示可跳过
  - 健康检查失败时自动回滚至备份标签并重启服务
  - 自动清理旧备份镜像(仅保留最近 3 个)
  - 支持 --yes 免交互、--tag 指定目标标签

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:58:05 +09:00

364 lines
14 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.
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# 个人资料库 — 一键更新脚本
# 支持镜像来源Gitea 镜像仓库(拉取)/ 本地源码(重新构建)
# 失败时自动回滚至上一版本
#
# 用法bash scripts/update.sh [--yes] [--tag <镜像标签>]
# --yes 跳过确认提示
# --tag <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 <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 "$@"