feat: 添加 Linux 一键部署和更新脚本
Some checks are pending
CI — Docker Build & Push / Build & Push Image (push) Waiting to run
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>
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,5 +1,4 @@
|
||||
# 强制 Shell 脚本使用 LF(在 Windows 上构建 Linux 镜像时必须)
|
||||
docker/entrypoint.sh text eol=lf
|
||||
# 强制 Shell 脚本使用 LF(在 Windows 上构建/运行时必须)
|
||||
*.sh text eol=lf
|
||||
|
||||
# 普通文件使用系统默认换行
|
||||
|
||||
497
scripts/deploy.sh
Normal file
497
scripts/deploy.sh
Normal file
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 个人资料库 — 一键部署脚本
|
||||
# 支持发行版:Ubuntu / Debian / CentOS / RHEL / Rocky / AlmaLinux /
|
||||
# Fedora / Alpine / Arch / Manjaro
|
||||
#
|
||||
# 用法:
|
||||
# curl -fsSL https://your-gitea/raw/.../scripts/deploy.sh | bash
|
||||
# 或:bash scripts/deploy.sh
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
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-deploy-$(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"; }
|
||||
ask() { echo -e "${YELLOW}[?]${NC} $*"; }
|
||||
|
||||
banner() {
|
||||
cat << 'EOF'
|
||||
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ 个人资料库 一键部署脚本 ║
|
||||
║ Personal Resource Library Deployer ║
|
||||
╚═══════════════════════════════════════════╝
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────
|
||||
command_exists() { command -v "$1" &>/dev/null; }
|
||||
|
||||
# 生成安全随机字符串
|
||||
gen_secret() {
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null ||
|
||||
openssl rand -hex 32 2>/dev/null ||
|
||||
tr -dc 'A-Za-z0-9@#$%' < /dev/urandom | head -c 64
|
||||
}
|
||||
|
||||
# 读取用户输入,支持默认值和可选的不回显(密码)
|
||||
prompt() {
|
||||
local var_name="$1" prompt_text="$2" default="${3:-}" secret="${4:-no}"
|
||||
local value=""
|
||||
if [ -n "$default" ]; then
|
||||
ask "$prompt_text [默认: ${secret:+***}${default:-}]: "
|
||||
else
|
||||
ask "$prompt_text: "
|
||||
fi
|
||||
if [ "$secret" = "yes" ]; then
|
||||
read -r -s value
|
||||
echo
|
||||
else
|
||||
read -r value
|
||||
fi
|
||||
value="${value:-$default}"
|
||||
eval "$var_name='$value'"
|
||||
}
|
||||
|
||||
# ── 环境检测 ──────────────────────────────────────────────────────
|
||||
check_bash_version() {
|
||||
local ver="${BASH_VERSINFO[0]:-0}"
|
||||
[ "$ver" -ge 4 ] || die "需要 Bash 4.0+,当前版本: $BASH_VERSION"
|
||||
}
|
||||
|
||||
check_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command_exists sudo; then
|
||||
warn "建议以 root 或 sudo 运行;继续尝试…"
|
||||
SUDO="sudo"
|
||||
else
|
||||
die "需要 root 权限(或安装 sudo)"
|
||||
fi
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
}
|
||||
|
||||
check_internet() {
|
||||
info "检测网络连通性…"
|
||||
if ! curl -fsSL --max-time 8 https://get.docker.com > /dev/null 2>&1 && \
|
||||
! wget -q --timeout=8 -O /dev/null https://get.docker.com 2>/dev/null; then
|
||||
warn "无法访问 https://get.docker.com,Docker 自动安装可能失败"
|
||||
warn "请手动安装 Docker 后重新运行此脚本"
|
||||
else
|
||||
ok "网络正常"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 发行版检测 ────────────────────────────────────────────────────
|
||||
detect_os() {
|
||||
OS_ID="unknown"; OS_PRETTY="Unknown"; PKG_MGR="unknown"
|
||||
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-unknown}"
|
||||
OS_PRETTY="${PRETTY_NAME:-$OS_ID}"
|
||||
OS_ID_LIKE="${ID_LIKE:-}"
|
||||
fi
|
||||
|
||||
# 根据 ID 或 ID_LIKE 映射包管理器
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian|linuxmint|pop|raspbian|kali)
|
||||
PKG_MGR="apt" ;;
|
||||
centos|rhel|ol|scientific)
|
||||
PKG_MGR="yum_or_dnf" ;;
|
||||
rocky|almalinux|eurolinux|springdale)
|
||||
PKG_MGR="dnf" ;;
|
||||
fedora)
|
||||
PKG_MGR="dnf" ;;
|
||||
alpine)
|
||||
PKG_MGR="apk" ;;
|
||||
arch|manjaro|endeavouros|garuda|artix)
|
||||
PKG_MGR="pacman" ;;
|
||||
opensuse*|sles)
|
||||
PKG_MGR="zypper" ;;
|
||||
*)
|
||||
if echo "${OS_ID_LIKE:-}" | grep -qiE "debian|ubuntu"; then
|
||||
PKG_MGR="apt"
|
||||
elif echo "${OS_ID_LIKE:-}" | grep -qiE "rhel|centos|fedora"; then
|
||||
PKG_MGR="yum_or_dnf"
|
||||
elif echo "${OS_ID_LIKE:-}" | grep -qiE "suse"; then
|
||||
PKG_MGR="zypper"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
info "操作系统: $OS_PRETTY (ID=$OS_ID, PKG=$PKG_MGR)"
|
||||
}
|
||||
|
||||
# ── Docker 安装 ───────────────────────────────────────────────────
|
||||
# 使用 Docker 官方安装脚本(支持大多数发行版),Alpine/Arch 走包管理器
|
||||
install_docker() {
|
||||
section "安装 Docker"
|
||||
|
||||
if command_exists docker; then
|
||||
local ver
|
||||
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1)
|
||||
ok "Docker 已安装: $ver"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "开始安装 Docker…"
|
||||
|
||||
case "$PKG_MGR" in
|
||||
apk)
|
||||
$SUDO apk add --no-cache docker docker-cli-compose
|
||||
$SUDO rc-update add docker boot 2>/dev/null || true
|
||||
$SUDO service docker start 2>/dev/null || true
|
||||
;;
|
||||
pacman)
|
||||
$SUDO pacman -Sy --noconfirm docker docker-compose
|
||||
$SUDO systemctl enable --now docker
|
||||
;;
|
||||
zypper)
|
||||
$SUDO zypper install -y docker docker-compose
|
||||
$SUDO systemctl enable --now docker
|
||||
;;
|
||||
*)
|
||||
# 官方安装脚本(Ubuntu/Debian/CentOS/RHEL/Rocky/Fedora 等)
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
curl -fsSL https://get.docker.com -o "$tmp" || \
|
||||
wget -qO "$tmp" https://get.docker.com || \
|
||||
die "下载 Docker 安装脚本失败"
|
||||
$SUDO sh "$tmp"
|
||||
rm -f "$tmp"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 启动并设置开机自启(systemd 系统)
|
||||
if command_exists systemctl && [ "$PKG_MGR" != "apk" ]; then
|
||||
$SUDO systemctl enable docker 2>/dev/null || true
|
||||
$SUDO systemctl start docker 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 将当前用户加入 docker 组(后续免 sudo)
|
||||
if [ -n "${SUDO_USER:-}" ] && getent group docker &>/dev/null; then
|
||||
$SUDO usermod -aG docker "$SUDO_USER"
|
||||
warn "已将 $SUDO_USER 加入 docker 组,需重新登录后生效"
|
||||
fi
|
||||
|
||||
ok "Docker 安装完成: $(docker --version 2>/dev/null || echo '(请重新登录后验证)')"
|
||||
}
|
||||
|
||||
# ── Docker Compose 检测 ───────────────────────────────────────────
|
||||
detect_compose() {
|
||||
if docker compose version &>/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
ok "Docker Compose v2 (plugin): $(docker compose version --short 2>/dev/null)"
|
||||
elif command_exists docker-compose; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
ok "Docker Compose v1 (standalone): $(docker-compose --version 2>/dev/null)"
|
||||
else
|
||||
info "未找到 Docker Compose,尝试安装 Compose 插件…"
|
||||
install_compose_plugin
|
||||
COMPOSE_CMD="docker compose"
|
||||
fi
|
||||
}
|
||||
|
||||
install_compose_plugin() {
|
||||
local COMPOSE_VER="v2.27.1"
|
||||
local ARCH; ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="x86_64" ;;
|
||||
aarch64|arm64) ARCH="aarch64" ;;
|
||||
armv7l) ARCH="armv7" ;;
|
||||
*) die "不支持的 CPU 架构: $ARCH" ;;
|
||||
esac
|
||||
|
||||
local URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${ARCH}"
|
||||
local DEST="/usr/local/lib/docker/cli-plugins/docker-compose"
|
||||
$SUDO mkdir -p "$(dirname "$DEST")"
|
||||
$SUDO curl -fsSL "$URL" -o "$DEST" || $SUDO wget -qO "$DEST" "$URL" || \
|
||||
die "下载 Docker Compose 插件失败"
|
||||
$SUDO chmod +x "$DEST"
|
||||
ok "Docker Compose 插件安装完成"
|
||||
}
|
||||
|
||||
# ── 交互配置 ──────────────────────────────────────────────────────
|
||||
collect_config() {
|
||||
section "部署配置"
|
||||
echo
|
||||
echo " 请选择部署模式:"
|
||||
echo " 1) 新建 MySQL(全栈,适合新服务器)"
|
||||
echo " 2) 使用现有 MySQL(适合已有数据库)"
|
||||
echo
|
||||
ask "请输入选项 [1/2](默认: 1): "
|
||||
read -r DEPLOY_MODE_INPUT
|
||||
DEPLOY_MODE_INPUT="${DEPLOY_MODE_INPUT:-1}"
|
||||
|
||||
case "$DEPLOY_MODE_INPUT" in
|
||||
1) DEPLOY_MODE="new-mysql" ; COMPOSE_FILE="docker-compose.yml" ; ENV_FILE=".env.docker" ;;
|
||||
2) DEPLOY_MODE="external-db" ; COMPOSE_FILE="docker-compose.external-db.yml" ; ENV_FILE=".env.external-db" ;;
|
||||
*) die "无效选项: $DEPLOY_MODE_INPUT" ;;
|
||||
esac
|
||||
ok "部署模式: $DEPLOY_MODE"
|
||||
|
||||
echo
|
||||
echo " 请选择镜像来源:"
|
||||
echo " 1) 从 Gitea 镜像仓库拉取(需已完成 CI 构建)"
|
||||
echo " 2) 本地构建(无需 CI,首次部署推荐)"
|
||||
echo
|
||||
ask "请输入选项 [1/2](默认: 2): "
|
||||
read -r IMG_SRC_INPUT
|
||||
IMG_SRC_INPUT="${IMG_SRC_INPUT:-2}"
|
||||
|
||||
case "$IMG_SRC_INPUT" in
|
||||
1) IMAGE_SOURCE="registry" ;;
|
||||
2) IMAGE_SOURCE="build" ;;
|
||||
*) die "无效选项: $IMG_SRC_INPUT" ;;
|
||||
esac
|
||||
|
||||
if [ "$IMAGE_SOURCE" = "registry" ]; then
|
||||
prompt REGISTRY_HOST "Gitea 镜像仓库地址" "git.hty1024.com"
|
||||
prompt REGISTRY_IMAGE "镜像名(小写)" "${REGISTRY_HOST}/hty1024/ai-app-database"
|
||||
prompt REGISTRY_USER "登录用户名" ""
|
||||
prompt REGISTRY_PASS "登录密码 / Token" "" yes
|
||||
|
||||
info "登录镜像仓库…"
|
||||
echo "$REGISTRY_PASS" | docker login "$REGISTRY_HOST" \
|
||||
-u "$REGISTRY_USER" --password-stdin || die "登录失败,请检查凭据"
|
||||
ok "镜像仓库登录成功"
|
||||
else
|
||||
REGISTRY_IMAGE=""
|
||||
fi
|
||||
|
||||
echo
|
||||
# ── 公共配置 ──────────────────────────────────────────────────
|
||||
local auto_secret; auto_secret=$(gen_secret)
|
||||
prompt APP_PORT "应用端口" "5000"
|
||||
prompt SECRET_KEY "应用密钥(留空自动生成)" "$auto_secret" yes
|
||||
SECRET_KEY="${SECRET_KEY:-$auto_secret}"
|
||||
|
||||
prompt ADMIN_USERNAME "管理员用户名" "admin"
|
||||
prompt ADMIN_PASSWORD "管理员密码" "Admin@123456" yes
|
||||
prompt ADMIN_EMAIL "管理员邮箱" "admin@example.com"
|
||||
prompt MAX_UPLOAD_MB "最大上传大小(MB)" "500"
|
||||
prompt GUNICORN_WORKERS "Gunicorn worker 数量" "4"
|
||||
|
||||
# ── 模式专属配置 ──────────────────────────────────────────────
|
||||
if [ "$DEPLOY_MODE" = "new-mysql" ]; then
|
||||
echo
|
||||
info "MySQL 配置(新建数据库)"
|
||||
local auto_root_pw; auto_root_pw=$(gen_secret | cut -c1-24)
|
||||
local auto_user_pw; auto_user_pw=$(gen_secret | cut -c1-24)
|
||||
prompt MYSQL_ROOT_PASSWORD "MySQL root 密码(留空自动生成)" "$auto_root_pw" yes
|
||||
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-$auto_root_pw}"
|
||||
prompt MYSQL_PASSWORD "MySQL 应用用户密码(留空自动生成)" "$auto_user_pw" yes
|
||||
MYSQL_PASSWORD="${MYSQL_PASSWORD:-$auto_user_pw}"
|
||||
prompt MYSQL_EXPOSE_PORT "MySQL 对外端口(留空=不暴露)" "127.0.0.1:3306"
|
||||
else
|
||||
echo
|
||||
info "外部数据库配置"
|
||||
echo " 格式: mysql+pymysql://用户名:密码@主机:端口/数据库名"
|
||||
prompt DATABASE_URL "数据库连接串" ""
|
||||
[ -n "$DATABASE_URL" ] || die "外部 MySQL 模式必须提供 DATABASE_URL"
|
||||
prompt DB_WAIT_SECONDS "等待数据库就绪超时(秒)" "30"
|
||||
fi
|
||||
|
||||
# ── 是否启用 Nginx ──────────────────────────────────────────
|
||||
echo
|
||||
ask "是否启用 Nginx 反向代理?[y/N](默认: N): "
|
||||
read -r NGINX_INPUT
|
||||
USE_NGINX="${NGINX_INPUT:-N}"
|
||||
if echo "$USE_NGINX" | grep -qiE "^y(es)?$"; then
|
||||
USE_NGINX="yes"
|
||||
prompt NGINX_HTTP_PORT "Nginx HTTP 端口" "80"
|
||||
prompt NGINX_HTTPS_PORT "Nginx HTTPS 端口" "443"
|
||||
else
|
||||
USE_NGINX="no"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 写入 env 文件 ─────────────────────────────────────────────────
|
||||
write_env_file() {
|
||||
section "写入配置文件"
|
||||
local env_path="$PROJECT_DIR/$ENV_FILE"
|
||||
|
||||
# 备份旧文件
|
||||
if [ -f "$env_path" ]; then
|
||||
cp "$env_path" "${env_path}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
warn "已备份旧配置: ${env_path}.bak.*"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# 个人资料库部署配置 — 生成于 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "# 请勿提交此文件到版本控制系统!"
|
||||
echo ""
|
||||
echo "SECRET_KEY=${SECRET_KEY}"
|
||||
echo "FLASK_ENV=production"
|
||||
echo "APP_PORT=${APP_PORT}"
|
||||
echo "MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_MB}"
|
||||
echo "GUNICORN_WORKERS=${GUNICORN_WORKERS}"
|
||||
echo "GUNICORN_TIMEOUT=120"
|
||||
echo "LOG_LEVEL=info"
|
||||
echo ""
|
||||
echo "ADMIN_USERNAME=${ADMIN_USERNAME}"
|
||||
echo "ADMIN_PASSWORD=${ADMIN_PASSWORD}"
|
||||
echo "ADMIN_EMAIL=${ADMIN_EMAIL}"
|
||||
echo ""
|
||||
|
||||
if [ "$DEPLOY_MODE" = "new-mysql" ]; then
|
||||
echo "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}"
|
||||
echo "MYSQL_DATABASE=resource_library"
|
||||
echo "MYSQL_USER=resource_library"
|
||||
echo "MYSQL_PASSWORD=${MYSQL_PASSWORD}"
|
||||
echo "MYSQL_EXPOSE_PORT=${MYSQL_EXPOSE_PORT:-127.0.0.1:3306}"
|
||||
else
|
||||
echo "DATABASE_URL=${DATABASE_URL}"
|
||||
echo "DB_WAIT_SECONDS=${DB_WAIT_SECONDS:-30}"
|
||||
fi
|
||||
|
||||
if [ "${USE_NGINX}" = "yes" ]; then
|
||||
echo ""
|
||||
echo "NGINX_HTTP_PORT=${NGINX_HTTP_PORT:-80}"
|
||||
echo "NGINX_HTTPS_PORT=${NGINX_HTTPS_PORT:-443}"
|
||||
fi
|
||||
} > "$env_path"
|
||||
|
||||
chmod 600 "$env_path"
|
||||
ok "配置文件已写入: $env_path"
|
||||
}
|
||||
|
||||
# ── 写入部署状态文件 ──────────────────────────────────────────────
|
||||
write_state_file() {
|
||||
{
|
||||
echo "DEPLOY_MODE=${DEPLOY_MODE}"
|
||||
echo "COMPOSE_FILE=${COMPOSE_FILE}"
|
||||
echo "ENV_FILE=${ENV_FILE}"
|
||||
echo "IMAGE_SOURCE=${IMAGE_SOURCE}"
|
||||
echo "REGISTRY_IMAGE=${REGISTRY_IMAGE:-}"
|
||||
echo "APP_PORT=${APP_PORT}"
|
||||
echo "USE_NGINX=${USE_NGINX}"
|
||||
echo "DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
} > "$STATE_FILE"
|
||||
ok "状态文件已写入: $STATE_FILE"
|
||||
}
|
||||
|
||||
# ── 构建或拉取镜像 ────────────────────────────────────────────────
|
||||
prepare_image() {
|
||||
section "准备镜像"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ "$IMAGE_SOURCE" = "registry" ]; then
|
||||
info "从镜像仓库拉取: ${REGISTRY_IMAGE}:latest …"
|
||||
docker pull "${REGISTRY_IMAGE}:latest" || die "镜像拉取失败"
|
||||
ok "镜像拉取完成"
|
||||
else
|
||||
info "本地构建镜像(首次构建可能需要几分钟)…"
|
||||
docker build -t resource-library:latest . 2>&1 | tee -a "$LOG_FILE"
|
||||
ok "本地构建完成"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 启动服务 ──────────────────────────────────────────────────────
|
||||
start_services() {
|
||||
section "启动服务"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE")
|
||||
|
||||
if [ "$USE_NGINX" = "yes" ]; then
|
||||
compose_args+=(--profile nginx)
|
||||
fi
|
||||
|
||||
info "执行: $COMPOSE_CMD ${compose_args[*]} up -d"
|
||||
$COMPOSE_CMD "${compose_args[@]}" up -d 2>&1 | tee -a "$LOG_FILE"
|
||||
ok "容器已启动"
|
||||
}
|
||||
|
||||
# ── 健康检查 ──────────────────────────────────────────────────────
|
||||
health_check() {
|
||||
section "健康检查"
|
||||
local max_wait=120 elapsed=0 interval=5
|
||||
local url="http://localhost:${APP_PORT}/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 "健康检查超时(${max_wait}s)"
|
||||
warn "查看日志: $COMPOSE_CMD -f $COMPOSE_FILE logs --tail=50"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 部署完成提示 ──────────────────────────────────────────────────
|
||||
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}${NC}"
|
||||
if [ "$USE_NGINX" = "yes" ]; then
|
||||
echo -e " Nginx: ${CYAN}http://${local_ip}:${NGINX_HTTP_PORT:-80}${NC}"
|
||||
fi
|
||||
echo
|
||||
echo -e " 管理员账号: ${YELLOW}${ADMIN_USERNAME}${NC}"
|
||||
echo -e " 管理员密码: ${YELLOW}(部署时设置)${NC}"
|
||||
echo
|
||||
echo -e " 查看日志: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE logs -f${NC}"
|
||||
echo -e " 更新应用: ${BLUE}bash scripts/update.sh${NC}"
|
||||
echo -e " 停止服务: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE down${NC}"
|
||||
echo
|
||||
echo -e " 部署日志: $LOG_FILE"
|
||||
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
|
||||
echo
|
||||
}
|
||||
|
||||
# ── 主流程 ────────────────────────────────────────────────────────
|
||||
main() {
|
||||
banner
|
||||
log "部署日志: $LOG_FILE"
|
||||
|
||||
check_bash_version
|
||||
check_root
|
||||
check_internet
|
||||
detect_os
|
||||
|
||||
install_docker
|
||||
detect_compose
|
||||
|
||||
collect_config
|
||||
write_env_file
|
||||
write_state_file
|
||||
prepare_image
|
||||
start_services
|
||||
health_check
|
||||
print_success
|
||||
}
|
||||
|
||||
main "$@"
|
||||
363
scripts/update.sh
Normal file
363
scripts/update.sh
Normal file
@@ -0,0 +1,363 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user