Some checks are pending
CI — Docker Build & Push / Build & Push Image (push) Waiting to run
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>
364 lines
14 KiB
Bash
364 lines
14 KiB
Bash
#!/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 "$@"
|