refactor(deploy): harden private upgrade workflow

这个提交包含在:
徐勤民 2026-07-29 00:11:07 +08:00
父节点 872a3331de
当前提交 44bd91d8e1
共有 24 个文件被更改,包括 128 次插入444 次删除

查看文件

@ -13,7 +13,7 @@
# 当前部署版本号,格式YYYY.MM.DD-private.N # 当前部署版本号,格式YYYY.MM.DD-private.N
# 用于镜像标签匹配和审计追溯 # 用于镜像标签匹配和审计追溯
PRIVATE_VERSION=2026.05.21-private.2 PRIVATE_VERSION=2026.07.28-private.1
# 安装目录的绝对路径deploy.sh 自动写入,用于容器内一键更新功能) # 安装目录的绝对路径deploy.sh 自动写入,用于容器内一键更新功能)
PRIVATE_DEPLOY_ROOT=/opt/xuqm-private PRIVATE_DEPLOY_ROOT=/opt/xuqm-private
@ -38,8 +38,8 @@ MANAGEMENT_HEALTH_MAIL_ENABLED=false
# ============================================================================= # =============================================================================
# #
# 可选值(用逗号分隔): # 可选值(用逗号分隔):
# base 必选,包含tenant-service / file-service / tenant-web / # base 必选,包含tenant-service / file-service / tenant-web / nginx;
# ops-web / docs-site / nginx # 文档站由 tenant-web 镜像在 /docs/ 同步提供,不存在独立容器
# infra-mysql 托管 MySQL 容器MYSQL_MODE=managed 时必选) # infra-mysql 托管 MySQL 容器MYSQL_MODE=managed 时必选)
# infra-redis 托管 Redis 容器REDIS_MODE=managed 时必选) # infra-redis 托管 Redis 容器REDIS_MODE=managed 时必选)
# im IM HTTP 和 WebSocket 服务 # im IM HTTP 和 WebSocket 服务

7
.gitignore vendored
查看文件

@ -6,13 +6,8 @@ data/redis/*
data/uploads/* data/uploads/*
data/update/* data/update/*
data/backups/* data/backups/*
!data/mysql/.gitkeep data/sourcemaps/*
!data/redis/.gitkeep
!data/uploads/.gitkeep
!data/update/.gitkeep
!data/backups/.gitkeep
dist/ dist/
*.tar.gz *.tar.gz
*.bak *.bak
.DS_Store .DS_Store

查看文件

@ -1,5 +1,16 @@
# 更新日志 # 更新日志
## 2026.07.28-private.1
- 私有化升级继承完整服务 profile,自动移除已废弃的 license profile,并在存在
BugCollect 定义时追加而不是覆盖原 profile,避免升级只拉取单一服务。
- 旧部署缺少 JWT 和应用商店监测密钥时各生成一次部署独享密钥,后续升级保持原值;
不再使用可公开推导的 JWT 默认值。
- 全量部署移除镜像仓库、MySQL 和 Redis 的可用默认密码;交互模式安全输入,
非交互模式必须显式注入只读仓库凭据,托管数据库密码自动随机生成。
- 删除未被入口调用的重复 configure/install/upgrade 实现、占位目录文件和不存在的
docs-site 独立镜像定义;文档站继续由 tenant-web 在 `/docs/` 提供。
## 2026.06.18-private.1 ## 2026.06.18-private.1
### 修复 ### 修复

查看文件

@ -10,6 +10,9 @@ curl -fsSL https://xuqinmin.com/xuqmGroup/XuqmGroup-PrivateDeploy/raw/branch/mai
``` ```
交互式向导依次完成:依赖检测 → 配置生成 → 镜像拉取 → 容器启动 → 租户初始化 → 全量验证。 交互式向导依次完成:依赖检测 → 配置生成 → 镜像拉取 → 容器启动 → 租户初始化 → 全量验证。
首次执行时会要求输入私有镜像仓库及只读拉取凭据;凭据只写入部署机器上被 Git
忽略的配置,不会进入部署仓库、业务镜像或普通文档。自动化部署通过
`REGISTRY`、`REGISTRY_USER`、`REGISTRY_PASSWORD` 环境变量传入。
### 升级已有部署 ### 升级已有部署
@ -37,7 +40,8 @@ bash scripts/reset.sh
| `install.sh` | 全新服务器首次部署;或彻底清除数据重装 | | `install.sh` | 全新服务器首次部署;或彻底清除数据重装 |
| `upgrade.sh` | 保留数据,更新部署脚本和镜像(推荐日常升级方式) | | `upgrade.sh` | 保留数据,更新部署脚本和镜像(推荐日常升级方式) |
| `scripts/reset.sh` | 保留数据,容器状态异常时快速重建恢复 | | `scripts/reset.sh` | 保留数据,容器状态异常时快速重建恢复 |
| `scripts/update.sh` | 在安装目录内执行的局部升级(由 upgrade.sh 调用) | | `scripts/deploy.sh` | 顶层安装器下载仓库后进入的唯一全量部署实现 |
| `scripts/update.sh` | 顶层升级器调用的唯一原地升级实现 |
| `scripts/verify.sh` | 随时重新运行全量验证 | | `scripts/verify.sh` | 随时重新运行全量验证 |
| `scripts/backup.sh` | 备份数据 | | `scripts/backup.sh` | 备份数据 |
| `scripts/restore.sh` | 恢复备份 | | `scripts/restore.sh` | 恢复备份 |

查看文件

@ -1 +1 @@
2026.05.21-private.2 2026.07.28-private.1

查看文件

@ -1 +0,0 @@

查看文件

@ -1 +0,0 @@

查看文件

@ -1 +0,0 @@

查看文件

@ -1 +0,0 @@

查看文件

@ -1 +0,0 @@

查看文件

@ -188,7 +188,7 @@ services:
SPRING_DATA_REDIS_PORT: "${REDIS_PORT:-6379}" SPRING_DATA_REDIS_PORT: "${REDIS_PORT:-6379}"
SPRING_DATA_REDIS_DATABASE: "${REDIS_DATABASE:-0}" SPRING_DATA_REDIS_DATABASE: "${REDIS_DATABASE:-0}"
SERVER_PORT: "9006" SERVER_PORT: "9006"
JWT_SECRET: "${JWT_SECRET:-xuqm-bugcollect-jwt-secret}" JWT_SECRET: "${XUQM_JWT_SECRET:?XUQM_JWT_SECRET must be configured in config/secrets.env}"
SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS: "*:Missing" SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS: "*:Missing"
SYMBOLICATOR_URL: "http://symbolicator:3000" SYMBOLICATOR_URL: "http://symbolicator:3000"
deploy: deploy:

查看文件

@ -1,12 +1,11 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"privateVersion": "2026.05.21-private.2", "privateVersion": "2026.07.28-private.1",
"registry": "registry.example.com/xuqm", "registry": "registry.example.com/xuqm",
"images": [ "images": [
{"name": "tenant-service", "required": true}, {"name": "tenant-service", "required": true},
{"name": "file-service", "required": true}, {"name": "file-service", "required": true},
{"name": "tenant-web", "required": true}, {"name": "tenant-web", "required": true},
{"name": "docs-site", "required": true},
{"name": "im-service", "required": false, "profile": "im"}, {"name": "im-service", "required": false, "profile": "im"},
{"name": "push-service", "required": false, "profile": "push"}, {"name": "push-service", "required": false, "profile": "push"},
{"name": "update-service", "required": false, "profile": "update"} {"name": "update-service", "required": false, "profile": "update"}

查看文件

@ -238,6 +238,11 @@ fi
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
tar -xzf "$TMP_PKG" -C "$INSTALL_DIR" --strip-components=1 --exclude='*/data' tar -xzf "$TMP_PKG" -C "$INSTALL_DIR" --strip-components=1 --exclude='*/data'
chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/install.sh" 2>/dev/null || true chmod +x "$INSTALL_DIR/scripts/"*.sh "$INSTALL_DIR/install.sh" 2>/dev/null || true
# tar 解压不会删除新版已移除的旧文件,必须显式清理已废弃的平行入口和占位文件。
rm -f "$INSTALL_DIR/scripts/configure.sh" \
"$INSTALL_DIR/scripts/install.sh" \
"$INSTALL_DIR/scripts/upgrade.sh"
find "$INSTALL_DIR/data" "$INSTALL_DIR/logs" -type f -name '.gitkeep' -delete 2>/dev/null || true
ok "部署包解压完成: $INSTALL_DIR" ok "部署包解压完成: $INSTALL_DIR"
# 恢复关键配置文件(确保现有配置不被模板覆盖) # 恢复关键配置文件(确保现有配置不被模板覆盖)

查看文件

@ -1 +0,0 @@

查看文件

@ -1,176 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
# Non-interactive mode: skip prompts if all required vars are already set
NON_INTERACTIVE="${NON_INTERACTIVE:-false}"
[ -t 0 ] || NON_INTERACTIVE=true
audit "configure" "STARTED" "rendering initial config"
progress "configure" "STARTED" "rendering initial config"
# Initialize from template if missing
if [ ! -f "$ROOT_DIR/.env" ]; then
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
fi
ensure_secret_file
load_env
ask() {
local prompt="$1"
local current="${2:-}"
local default="${3:-}"
[ "$NON_INTERACTIVE" = "true" ] && { printf '%s' "${current:-$default}"; return; }
local display_default=""
[ -n "$current" ] && display_default=" [current: $current]"
[ -z "$current" ] && [ -n "$default" ] && display_default=" [default: $default]"
printf '%s%s: ' "$prompt" "$display_default" >&2
read -r value
printf '%s' "${value:-${current:-$default}}"
}
ask_secret() {
local prompt="$1"
[ "$NON_INTERACTIVE" = "true" ] && { printf 'change-me'; return; }
printf '%s (leave blank to generate): ' "$prompt" >&2
read -rs value
printf '\n' >&2
if [ -z "$value" ]; then
value="$(random_secret)"
printf ' → generated strong password\n' >&2
fi
printf '%s' "$value"
}
printf '\n=== XuqmGroup Private Deployment Configuration ===\n\n'
# Domains
CONSOLE_DOMAIN="$(ask "Console domain (e.g. https://console.customer.com)" "${CONSOLE_DOMAIN:-}" "")"
OPS_DOMAIN="$(ask "Ops domain (e.g. https://ops.customer.com)" "${OPS_DOMAIN:-}" "")"
DOCS_DOMAIN="$(ask "Docs domain (e.g. https://docs.customer.com)" "${DOCS_DOMAIN:-}" "")"
FILE_DOMAIN="$(ask "File domain (e.g. https://file.customer.com)" "${FILE_DOMAIN:-}" "")"
# Optional service domains (only ask if enabling)
ENABLE_IM="${ENABLE_IM:-false}"
ENABLE_PUSH="${ENABLE_PUSH:-false}"
ENABLE_UPDATE="${ENABLE_UPDATE:-false}"
if [ "$NON_INTERACTIVE" != "true" ]; then
printf '\nWhich optional services to enable now? (space-separated, e.g. "im update")\n'
printf 'Available: im push update\nCurrent: %s\n' "$COMPOSE_PROFILES"
printf 'Enter services (blank to keep current): '
read -r OPT_SVCS
fi
if printf '%s' "${OPT_SVCS:-}" | grep -q 'im'; then ENABLE_IM=true; IM_DOMAIN="$(ask "IM domain (e.g. https://im.customer.com)" "${IM_DOMAIN:-}" "")"; fi
if printf '%s' "${OPT_SVCS:-}" | grep -q 'push'; then ENABLE_PUSH=true; PUSH_DOMAIN="$(ask "Push domain" "${PUSH_DOMAIN:-}" "")"; fi
if printf '%s' "${OPT_SVCS:-}" | grep -q 'update'; then ENABLE_UPDATE=true; UPDATE_DOMAIN="$(ask "Update domain" "${UPDATE_DOMAIN:-}" "")"; fi
# MySQL mode
printf '\nMySQL mode (external=you provide connection / managed=deploy script installs MySQL)\n'
MYSQL_MODE="$(ask "MySQL mode" "${MYSQL_MODE:-external}" "external")"
if [ "$MYSQL_MODE" = "external" ]; then
MYSQL_HOST="$(ask "MySQL host" "${MYSQL_HOST:-127.0.0.1}" "127.0.0.1")"
MYSQL_PORT="$(ask "MySQL port" "${MYSQL_PORT:-3306}" "3306")"
MYSQL_DATABASE="$(ask "MySQL database" "${MYSQL_DATABASE:-xuqm_private}" "xuqm_private")"
MYSQL_USERNAME="$(ask "MySQL username" "${MYSQL_USERNAME:-xuqm}" "xuqm")"
MYSQL_PASSWORD="$(ask_secret "MySQL password")"
else
MYSQL_HOST="mysql"
MYSQL_PORT="3306"
MYSQL_DATABASE="$(ask "MySQL database" "${MYSQL_DATABASE:-xuqm_private}" "xuqm_private")"
MYSQL_USERNAME="$(ask "MySQL username" "${MYSQL_USERNAME:-xuqm}" "xuqm")"
MYSQL_PASSWORD="$(ensure_env_value "$ROOT_DIR/config/secrets.env" "MYSQL_PASSWORD" "${MYSQL_PASSWORD:-}" "$(random_secret)")"
MYSQL_ROOT_PASSWORD="$(ensure_env_value "$ROOT_DIR/config/secrets.env" "MYSQL_ROOT_PASSWORD" "${MYSQL_ROOT_PASSWORD:-}" "$(random_secret)")"
fi
# Redis mode
printf '\nRedis mode (external=you provide connection / managed=deploy script installs Redis)\n'
REDIS_MODE="$(ask "Redis mode" "${REDIS_MODE:-external}" "external")"
if [ "$REDIS_MODE" = "external" ]; then
REDIS_HOST="$(ask "Redis host" "${REDIS_HOST:-127.0.0.1}" "127.0.0.1")"
REDIS_PORT="$(ask "Redis port" "${REDIS_PORT:-6379}" "6379")"
REDIS_PASSWORD="$(ask_secret "Redis password")"
else
REDIS_HOST="redis"
REDIS_PORT="6379"
REDIS_PASSWORD="$(ensure_env_value "$ROOT_DIR/config/secrets.env" "REDIS_PASSWORD" "${REDIS_PASSWORD:-}" "$(random_secret)")"
fi
# Bootstrap tenant
printf '\nBootstrap admin account:\n'
TENANT_BOOTSTRAP_EMAIL="$(ask "Admin email" "${TENANT_BOOTSTRAP_EMAIL:-admin@customer.com}" "admin@customer.com")"
TENANT_BOOTSTRAP_APP_KEY="$(ask "App key" "${TENANT_BOOTSTRAP_APP_KEY:-ak_private_default}" "ak_private_default")"
# Registry
REGISTRY="$(ask "Docker registry" "${REGISTRY:-registry.example.com/xuqm}" "registry.example.com/xuqm")"
IMAGE_TAG="$(ask "Image tag" "${IMAGE_TAG:-$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')}" "")"
# Write .env (compose variable substitution — used by ${VAR} in docker-compose.yml)
set_env_value "$ROOT_DIR/.env" "CONSOLE_DOMAIN" "$CONSOLE_DOMAIN"
set_env_value "$ROOT_DIR/.env" "OPS_DOMAIN" "$OPS_DOMAIN"
set_env_value "$ROOT_DIR/.env" "DOCS_DOMAIN" "$DOCS_DOMAIN"
set_env_value "$ROOT_DIR/.env" "FILE_DOMAIN" "$FILE_DOMAIN"
[ -n "${IM_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/.env" "IM_DOMAIN" "$IM_DOMAIN"
[ -n "${PUSH_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/.env" "PUSH_DOMAIN" "$PUSH_DOMAIN"
[ -n "${UPDATE_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/.env" "UPDATE_DOMAIN" "$UPDATE_DOMAIN"
set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "$ENABLE_IM"
set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "$ENABLE_PUSH"
set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "$ENABLE_UPDATE"
set_env_value "$ROOT_DIR/.env" "MYSQL_MODE" "$MYSQL_MODE"
set_env_value "$ROOT_DIR/.env" "MYSQL_HOST" "$MYSQL_HOST"
set_env_value "$ROOT_DIR/.env" "MYSQL_PORT" "$MYSQL_PORT"
set_env_value "$ROOT_DIR/.env" "MYSQL_DATABASE" "$MYSQL_DATABASE"
set_env_value "$ROOT_DIR/.env" "MYSQL_USERNAME" "$MYSQL_USERNAME"
set_env_value "$ROOT_DIR/.env" "REDIS_MODE" "$REDIS_MODE"
set_env_value "$ROOT_DIR/.env" "REDIS_HOST" "$REDIS_HOST"
set_env_value "$ROOT_DIR/.env" "REDIS_PORT" "$REDIS_PORT"
set_env_value "$ROOT_DIR/.env" "REGISTRY" "$REGISTRY"
set_env_value "$ROOT_DIR/.env" "IMAGE_TAG" "$IMAGE_TAG"
set_env_value "$ROOT_DIR/.env" "TENANT_BOOTSTRAP_EMAIL" "$TENANT_BOOTSTRAP_EMAIL"
set_env_value "$ROOT_DIR/.env" "TENANT_BOOTSTRAP_APP_KEY" "$TENANT_BOOTSTRAP_APP_KEY"
# Sync infra settings into xuqm.env so containers receive correct host/port values
# via env_file:, consistent with the SPRING_DATASOURCE_* overrides in docker-compose.yml.
XUQM_ENV="$ROOT_DIR/config/xuqm.env"
set_env_value "$XUQM_ENV" "MYSQL_HOST" "$MYSQL_HOST"
set_env_value "$XUQM_ENV" "MYSQL_PORT" "$MYSQL_PORT"
set_env_value "$XUQM_ENV" "MYSQL_DATABASE" "$MYSQL_DATABASE"
set_env_value "$XUQM_ENV" "MYSQL_USERNAME" "$MYSQL_USERNAME"
set_env_value "$XUQM_ENV" "REDIS_HOST" "$REDIS_HOST"
set_env_value "$XUQM_ENV" "REDIS_PORT" "$REDIS_PORT"
set_env_value "$XUQM_ENV" "CONSOLE_DOMAIN" "$CONSOLE_DOMAIN"
[ -n "${FILE_DOMAIN:-}" ] && set_env_value "$XUQM_ENV" "FILE_DOMAIN" "$FILE_DOMAIN"
[ -n "${IM_DOMAIN:-}" ] && set_env_value "$XUQM_ENV" "IM_DOMAIN" "$IM_DOMAIN"
[ -n "${PUSH_DOMAIN:-}" ] && set_env_value "$XUQM_ENV" "PUSH_DOMAIN" "$PUSH_DOMAIN"
[ -n "${UPDATE_DOMAIN:-}" ] && set_env_value "$XUQM_ENV" "UPDATE_DOMAIN" "$UPDATE_DOMAIN"
set_env_value "$XUQM_ENV" "ENABLE_IM" "$ENABLE_IM"
set_env_value "$XUQM_ENV" "ENABLE_PUSH" "$ENABLE_PUSH"
set_env_value "$XUQM_ENV" "ENABLE_UPDATE" "$ENABLE_UPDATE"
# Write secrets.env
set_env_value "$ROOT_DIR/config/secrets.env" "MYSQL_PASSWORD" "$MYSQL_PASSWORD"
[ -n "${MYSQL_ROOT_PASSWORD:-}" ] && \
set_env_value "$ROOT_DIR/config/secrets.env" "MYSQL_ROOT_PASSWORD" "$MYSQL_ROOT_PASSWORD"
set_env_value "$ROOT_DIR/config/secrets.env" "REDIS_PASSWORD" "$REDIS_PASSWORD"
set_env_value "$ROOT_DIR/config/secrets.env" "SPRING_DATASOURCE_PASSWORD" "$MYSQL_PASSWORD"
set_env_value "$ROOT_DIR/config/secrets.env" "SPRING_DATA_REDIS_PASSWORD" "$REDIS_PASSWORD"
ensure_env_value "$ROOT_DIR/config/secrets.env" "XUQM_JWT_SECRET" \
"${XUQM_JWT_SECRET:-}" "$(random_secret)$(random_secret)" >/dev/null
ensure_env_value "$ROOT_DIR/config/secrets.env" "SDK_INTERNAL_TOKEN" \
"${SDK_INTERNAL_TOKEN:-}" "$(random_secret)" >/dev/null
ensure_env_value "$ROOT_DIR/config/secrets.env" "XUQM_STORE_SECRET_MASTER_KEY" \
"${XUQM_STORE_SECRET_MASTER_KEY:-}" "$(random_aes256_base64)" >/dev/null
set_env_value "$ROOT_DIR/config/secrets.env" "XUQM_STORE_SECRET_KEY_VERSION" \
"${XUQM_STORE_SECRET_KEY_VERSION:-v1}"
chmod 600 "$ROOT_DIR/config/secrets.env"
"$ROOT_DIR/scripts/render-config.sh"
audit "configure" "DONE" "config ready"
progress "configure" "DONE" "config ready"
printf '\nConfiguration complete. Review .env and config/secrets.env before deployment.\n'

查看文件

@ -23,8 +23,8 @@
# 覆盖默认值(可通过环境变量传入): # 覆盖默认值(可通过环境变量传入):
# DEPLOY_HOST 目标机器 IP / 主机名(默认 localhost # DEPLOY_HOST 目标机器 IP / 主机名(默认 localhost
# 部署向导会提示配置域名与 HTTPS # 部署向导会提示配置域名与 HTTPS
# REGISTRY_PASSWORD ACR 密码(默认 xuqinmin1022 # REGISTRY / REGISTRY_USER / REGISTRY_PASSWORD 私有镜像仓库与只读拉取凭据
# MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD 同理 # MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD 可显式传入;未传时随机生成
set -euo pipefail set -euo pipefail
@ -33,11 +33,11 @@ set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 镜像仓库 # 镜像仓库。客户交付包不得内置可用凭据;仓库地址与只读拉取账号由交付环境提供。
REGISTRY="crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com/xuqmgroup" REGISTRY="${REGISTRY:-registry.example.com/xuqm}"
REGISTRY_HOST="crpi-n44qjpuucgjt8e8c.cn-beijing.personal.cr.aliyuncs.com" REGISTRY_HOST="${REGISTRY%%/*}"
REGISTRY_USER="xuqinmin12" REGISTRY_USER="${REGISTRY_USER:-}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-xuqinmin1022}" REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}"
IMAGE_TAG="latest" IMAGE_TAG="latest"
# 部署主机(用于健康检查 HTTP 请求,直接打服务端口) # 部署主机(用于健康检查 HTTP 请求,直接打服务端口)
@ -46,13 +46,13 @@ CONSOLE_BASE="http://${DEPLOY_HOST}"
_WS_SCHEME="ws" _WS_SCHEME="ws"
# MySQLmanaged 模式,由 Docker 容器托管) # MySQLmanaged 模式,由 Docker 容器托管)
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-XuqmRoot@2026}" MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-XuqmMysql@2026}" MYSQL_PASSWORD="${MYSQL_PASSWORD:-}"
MYSQL_DATABASE="xuqm_private" MYSQL_DATABASE="xuqm_private"
MYSQL_USERNAME="xuqm" MYSQL_USERNAME="xuqm"
# Redismanaged 模式) # Redismanaged 模式)
REDIS_PASSWORD="${REDIS_PASSWORD:-XuqmRedis@2026}" REDIS_PASSWORD="${REDIS_PASSWORD:-}"
# 公有化平台地址(迁移时调用 export API # 公有化平台地址(迁移时调用 export API
PUBLIC_PLATFORM_URL="https://dev.xuqinmin.com" PUBLIC_PLATFORM_URL="https://dev.xuqinmin.com"
@ -61,6 +61,7 @@ PUBLIC_PLATFORM_URL="https://dev.xuqinmin.com"
# 内部变量 # 内部变量
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PRIVATE_VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")"
STEP=0 STEP=0
TOTAL_STEPS=7 TOTAL_STEPS=7
@ -74,6 +75,14 @@ warn() { printf ' \033[33m⚠\033[0m %s\n' "$*"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
step() { STEP=$((STEP+1)); log "$*"; } step() { STEP=$((STEP+1)); log "$*"; }
random_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 32 | tr -d '\n'
else
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d '\n'
fi
}
container_running() { container_running() {
docker ps --filter "name=$1" --filter "status=running" --format '{{.Names}}' 2>/dev/null | grep -q "$1" docker ps --filter "name=$1" --filter "status=running" --format '{{.Names}}' 2>/dev/null | grep -q "$1"
} }
@ -86,6 +95,29 @@ container_running() {
cd "$ROOT_DIR" cd "$ROOT_DIR"
if [ "${XUQM_NONINTERACTIVE:-0}" != "1" ]; then
if [ "$REGISTRY" = "registry.example.com/xuqm" ]; then
read -rp " 私有镜像仓库(不含协议): " REGISTRY
REGISTRY_HOST="${REGISTRY%%/*}"
fi
[ -n "$REGISTRY_USER" ] || read -rp " 镜像仓库只读用户名: " REGISTRY_USER
if [ -z "$REGISTRY_PASSWORD" ]; then
read -rsp " 镜像仓库只读密码/令牌: " REGISTRY_PASSWORD
printf '\n'
fi
fi
[ -n "$REGISTRY" ] && [ "$REGISTRY" != "registry.example.com/xuqm" ] || \
fail "请指定私有镜像仓库地址"
[ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_PASSWORD" ] || \
fail "请提供只读镜像拉取凭据"
# 托管数据库和 Redis 的密码只在首次部署进程中随机生成,随后写入被 Git 忽略的
# config/secrets.env;源码、镜像和普通文档中不保留任何可用默认密码。
[ -n "$MYSQL_ROOT_PASSWORD" ] || MYSQL_ROOT_PASSWORD="$(random_secret)"
[ -n "$MYSQL_PASSWORD" ] || MYSQL_PASSWORD="$(random_secret)"
[ -n "$REDIS_PASSWORD" ] || REDIS_PASSWORD="$(random_secret)"
printf '\n\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf '\n\033[1;35m══════════════════════════════════════════════════\033[0m\n'
printf '\033[1;35m XuqmGroup 私有化一键部署脚本(全量部署)\033[0m\n' printf '\033[1;35m XuqmGroup 私有化一键部署脚本(全量部署)\033[0m\n'
printf '\033[1;35m══════════════════════════════════════════════════\033[0m\n' printf '\033[1;35m══════════════════════════════════════════════════\033[0m\n'
@ -326,7 +358,7 @@ cat > "$ROOT_DIR/.env" <<EOF
# 部署主机: ${DEPLOY_HOST} # 部署主机: ${DEPLOY_HOST}
# ============================================================================= # =============================================================================
PRIVATE_VERSION=2026.05.21-private.2 PRIVATE_VERSION=${PRIVATE_VERSION}
PRIVATE_DEPLOY_ROOT=${ROOT_DIR} PRIVATE_DEPLOY_ROOT=${ROOT_DIR}
REGISTRY=${REGISTRY} REGISTRY=${REGISTRY}
REGISTRY_USER=${REGISTRY_USER} REGISTRY_USER=${REGISTRY_USER}
@ -510,7 +542,7 @@ mkdir -p "$ROOT_DIR/config/sdk" "$ROOT_DIR/config/docs" "$ROOT_DIR/.deploy-state
cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" <<EOF cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" <<EOF
{ {
"schemaVersion": 1, "schemaVersion": 1,
"configVersion": "2026.05.19-private.1", "configVersion": "${PRIVATE_VERSION}",
"deployment": "PRIVATE", "deployment": "PRIVATE",
"appKey": "ak_private_default", "appKey": "ak_private_default",
"controlBaseUrl": "${CONSOLE_BASE}", "controlBaseUrl": "${CONSOLE_BASE}",
@ -532,7 +564,7 @@ EOF
cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF
{ {
"deployment": "PRIVATE", "deployment": "PRIVATE",
"privateVersion": "2026.05.19-private.1", "privateVersion": "${PRIVATE_VERSION}",
"domains": { "domains": {
"console": "${CONSOLE_BASE}", "console": "${CONSOLE_BASE}",
"file": "${CONSOLE_BASE}", "file": "${CONSOLE_BASE}",

查看文件

@ -24,7 +24,7 @@ else
check_fail "docker" "daemon not available" check_fail "docker" "daemon not available"
fi fi
# Container states — docs-site is optional (image may not exist in all registries) # 必选容器。文档站由 tenant-web 镜像在 /docs/ 提供,不存在独立 docs-site 容器。
for svc in tenant-service file-service tenant-web nginx; do for svc in tenant-service file-service tenant-web nginx; do
STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)" STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)"
if [ -n "$STATE" ]; then if [ -n "$STATE" ]; then
@ -39,8 +39,8 @@ for svc in tenant-service file-service tenant-web nginx; do
fi fi
done done
# Optional service containers (docs-site included here — warn only if present but unhealthy) # 可选服务容器。
for svc in docs-site im-service push-service update-service; do for svc in im-service push-service update-service; do
STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)" STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)"
if [ -n "$STATE" ]; then if [ -n "$STATE" ]; then
STATUS="$(docker inspect --format='{{.State.Status}}' "$STATE" 2>/dev/null || echo 'unknown')" STATUS="$(docker inspect --format='{{.State.Status}}' "$STATE" 2>/dev/null || echo 'unknown')"

查看文件

@ -1,131 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
if [ ! -f "$ROOT_DIR/.env" ]; then
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
fi
ensure_secret_file
load_env
PROFILE="${COMPOSE_PROFILES:-base}"
OFFLINE_BUNDLE=""
SKIP_LOGIN=false
usage() {
cat <<'EOF'
Usage: ./scripts/install.sh [options]
Options:
--profile <profiles> Docker Compose profiles, for example: base,im,push,update
--mysql-mode <mode> external or managed
--redis-mode <mode> external or managed
--offline-bundle <path> Load images from an offline bundle before deployment
--skip-registry-login Skip docker login (use when already logged in or in offline mode)
-h, --help Show help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-registry-login)
SKIP_LOGIN=true
shift
;;
--profile)
PROFILE="${2:-}"
shift 2
;;
--profile=*)
PROFILE="${1#--profile=}"
shift
;;
--mysql-mode)
MYSQL_MODE="${2:-}"
set_env_value "$ROOT_DIR/.env" "MYSQL_MODE" "$MYSQL_MODE"
set_env_value "$ROOT_DIR/config/infra.env" "MYSQL_MODE" "$MYSQL_MODE"
shift 2
;;
--mysql-mode=*)
MYSQL_MODE="${1#--mysql-mode=}"
set_env_value "$ROOT_DIR/.env" "MYSQL_MODE" "$MYSQL_MODE"
set_env_value "$ROOT_DIR/config/infra.env" "MYSQL_MODE" "$MYSQL_MODE"
shift
;;
--redis-mode)
REDIS_MODE="${2:-}"
set_env_value "$ROOT_DIR/.env" "REDIS_MODE" "$REDIS_MODE"
set_env_value "$ROOT_DIR/config/infra.env" "REDIS_MODE" "$REDIS_MODE"
shift 2
;;
--redis-mode=*)
REDIS_MODE="${1#--redis-mode=}"
set_env_value "$ROOT_DIR/.env" "REDIS_MODE" "$REDIS_MODE"
set_env_value "$ROOT_DIR/config/infra.env" "REDIS_MODE" "$REDIS_MODE"
shift
;;
--offline-bundle)
OFFLINE_BUNDLE="${2:-}"
shift 2
;;
--offline-bundle=*)
OFFLINE_BUNDLE="${1#--offline-bundle=}"
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail_json "XUQM_PRIVATE_4002" "unknown install option: $1" "install"
;;
esac
done
[ -n "$PROFILE" ] || fail_json "XUQM_PRIVATE_4003" "profile cannot be empty" "install"
set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$PROFILE"
audit "install" "STARTED" "profile=$PROFILE"
progress "install" "STARTED" "profile=$PROFILE"
require_cmd docker
if [ -n "$OFFLINE_BUNDLE" ]; then
[ -f "$OFFLINE_BUNDLE" ] || fail_json "XUQM_PRIVATE_4004" "offline bundle not found: $OFFLINE_BUNDLE" "install"
docker load -i "$OFFLINE_BUNDLE"
SKIP_LOGIN=true
fi
# Login to image registry (skip for offline bundles or when already authenticated)
if [ "$SKIP_LOGIN" = "false" ] && [ -n "${REGISTRY:-}" ]; then
REGISTRY_HOST="$(printf '%s' "$REGISTRY" | cut -d/ -f1)"
REGISTRY_USER="${REGISTRY_USER:-}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}"
if [ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_PASSWORD" ]; then
printf '%s' "$REGISTRY_PASSWORD" | \
docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin \
|| fail_json "XUQM_PRIVATE_4005" "docker login failed for $REGISTRY_HOST" "install"
audit "install" "REGISTRY_LOGIN_OK" "host=$REGISTRY_HOST"
else
audit "install" "REGISTRY_LOGIN_SKIPPED" "REGISTRY_USER/REGISTRY_PASSWORD not set; assuming pre-authenticated"
fi
fi
if [ "${MYSQL_MODE:-external}" = "managed" ]; then
"$ROOT_DIR/scripts/install-mysql.sh"
fi
if [ "${REDIS_MODE:-external}" = "managed" ]; then
"$ROOT_DIR/scripts/install-redis.sh"
fi
"$ROOT_DIR/scripts/render-config.sh"
COMPOSE_PROFILES="$PROFILE" compose up -d
"$ROOT_DIR/scripts/healthcheck.sh"
audit "install" "DONE" "profile=$PROFILE"
progress "install" "DONE" "profile=$PROFILE"

查看文件

@ -4,6 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh" . "$ROOT_DIR/scripts/lib.sh"
load_env load_env
PRIVATE_VERSION="${PRIVATE_VERSION:-$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")}"
strip_protocol() { strip_protocol() {
local value="$1" local value="$1"
@ -31,7 +32,7 @@ mkdir -p "$ROOT_DIR/config/docs" "$ROOT_DIR/config/sdk"
cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF cat > "$ROOT_DIR/config/docs/docs-runtime.json" <<EOF
{ {
"deployment": "PRIVATE", "deployment": "PRIVATE",
"privateVersion": "${PRIVATE_VERSION:-2026.05.18-private.1}", "privateVersion": "${PRIVATE_VERSION}",
"domains": { "domains": {
"console": "${CONSOLE_DOMAIN:-}", "console": "${CONSOLE_DOMAIN:-}",
"docs": "${DOCS_DOMAIN:-}", "docs": "${DOCS_DOMAIN:-}",
@ -52,7 +53,7 @@ EOF
cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" <<EOF cat > "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" <<EOF
{ {
"schemaVersion": 1, "schemaVersion": 1,
"configVersion": "${PRIVATE_VERSION:-2026.05.18-private.1}", "configVersion": "${PRIVATE_VERSION}",
"deployment": "PRIVATE", "deployment": "PRIVATE",
"appKey": "${TENANT_BOOTSTRAP_APP_KEY:-ak_private_default}", "appKey": "${TENANT_BOOTSTRAP_APP_KEY:-ak_private_default}",
"controlBaseUrl": "${CONSOLE_DOMAIN:-}", "controlBaseUrl": "${CONSOLE_DOMAIN:-}",
@ -76,7 +77,7 @@ EOF
cat > "$ROOT_DIR/.deploy-state/current.json" <<EOF cat > "$ROOT_DIR/.deploy-state/current.json" <<EOF
{ {
"privateVersion": "${PRIVATE_VERSION:-2026.05.18-private.1}", "privateVersion": "${PRIVATE_VERSION}",
"profiles": ["${COMPOSE_PROFILES:-base}"], "profiles": ["${COMPOSE_PROFILES:-base}"],
"mysql": {"mode": "${MYSQL_MODE:-external}", "status": "UNKNOWN"}, "mysql": {"mode": "${MYSQL_MODE:-external}", "status": "UNKNOWN"},
"redis": {"mode": "${REDIS_MODE:-external}", "status": "UNKNOWN"}, "redis": {"mode": "${REDIS_MODE:-external}", "status": "UNKNOWN"},

查看文件

@ -108,6 +108,12 @@ set -a
. "$ROOT_DIR/config/secrets.env" . "$ROOT_DIR/config/secrets.env"
set +a set +a
_PACKAGE_VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")"
[ -n "$_PACKAGE_VERSION" ] || fail "VERSION 文件为空,无法确认升级目标版本"
_set_env "$ROOT_DIR/.env" "PRIVATE_VERSION" "$_PACKAGE_VERSION"
export PRIVATE_VERSION="$_PACKAGE_VERSION"
ok "目标私有化版本=${PRIVATE_VERSION}"
# 迁移:旧版 secrets.env 没有 Spring 直接读取的 env 名,补齐以防 docker compose # 迁移:旧版 secrets.env 没有 Spring 直接读取的 env 名,补齐以防 docker compose
# 变量替换失败时覆盖成空字符串deploy.sh 新建部署已自动写入,此处仅升级兼容) # 变量替换失败时覆盖成空字符串deploy.sh 新建部署已自动写入,此处仅升级兼容)
_patched=0 _patched=0
@ -123,6 +129,19 @@ if ! grep -q "^SPRING_DATA_REDIS_PASSWORD=" "$ROOT_DIR/config/secrets.env" 2>/de
fi fi
[ "$_patched" -eq 1 ] && ok "secrets.env 已补齐 SPRING_DATASOURCE_PASSWORD / SPRING_DATA_REDIS_PASSWORD" [ "$_patched" -eq 1 ] && ok "secrets.env 已补齐 SPRING_DATASOURCE_PASSWORD / SPRING_DATA_REDIS_PASSWORD"
# 旧私有化部署可能依赖各服务源码中的 JWT 默认值。新版本不允许继续使用公开默认
# 密钥,因此升级时生成一次部署独享密钥并持久化;后续升级必须原值保留。
if ! grep -q "^XUQM_JWT_SECRET=." "$ROOT_DIR/config/secrets.env" 2>/dev/null; then
if command -v openssl >/dev/null 2>&1; then
_jwt_secret="$(openssl rand -base64 48 | tr -d '\n')"
else
_jwt_secret="$(dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | tr -d '\n')"
fi
printf 'XUQM_JWT_SECRET=%s\n' "$_jwt_secret" >> "$ROOT_DIR/config/secrets.env"
chmod 600 "$ROOT_DIR/config/secrets.env"
ok "secrets.env 已生成部署独享 JWT 密钥(现有登录会话需重新登录)"
fi
# 商店监测凭据使用独立的 AES-256-GCM 主密钥。升级旧部署时只生成一次, # 商店监测凭据使用独立的 AES-256-GCM 主密钥。升级旧部署时只生成一次,
# 后续更新必须保留原值,否则已有商店凭据将无法解密。 # 后续更新必须保留原值,否则已有商店凭据将无法解密。
if ! grep -q "^XUQM_STORE_SECRET_MASTER_KEY=." "$ROOT_DIR/config/secrets.env" 2>/dev/null; then if ! grep -q "^XUQM_STORE_SECRET_MASTER_KEY=." "$ROOT_DIR/config/secrets.env" 2>/dev/null; then
@ -153,14 +172,26 @@ ok "NGINX_BIND=${_NGINX_BIND}"
[ -n "$_CONSOLE_DOMAIN" ] || fail "config/xuqm.env 中 CONSOLE_DOMAIN 未设置,请手动补充后重试" [ -n "$_CONSOLE_DOMAIN" ] || fail "config/xuqm.env 中 CONSOLE_DOMAIN 未设置,请手动补充后重试"
# 若 docker-compose.yml 包含 bugcollect-service 定义,激活 bugcollect profile, # 继承现有部署的全部 profile,删除已经废弃的 license;若 compose 包含
# 确保 --remove-orphans 不把该服务的容器当孤儿停掉 # bugcollect-service,则补充 bugcollect。禁止只启用 bugcollect 后误把 base/update
_ACTIVE_PROFILES="" # 等服务排除在拉取与编排计划之外。
_ACTIVE_PROFILES="$(
printf '%s' "${COMPOSE_PROFILES:-base}" |
tr ',' '\n' |
awk 'NF && $0 != "license" && !seen[$0]++ { values[++n]=$0 }
END { for (i=1; i<=n; i++) printf "%s%s", (i>1 ? "," : ""), values[i] }'
)"
[ -n "$_ACTIVE_PROFILES" ] || _ACTIVE_PROFILES="base"
if grep -q 'bugcollect-service:' "$ROOT_DIR/docker-compose.yml" 2>/dev/null; then if grep -q 'bugcollect-service:' "$ROOT_DIR/docker-compose.yml" 2>/dev/null; then
_ACTIVE_PROFILES="bugcollect" case ",${_ACTIVE_PROFILES}," in
ok "docker-compose.yml 包含 bugcollect-service → 激活 bugcollect profile" *,bugcollect,*) ;;
*) _ACTIVE_PROFILES="${_ACTIVE_PROFILES},bugcollect" ;;
esac
ok "docker-compose.yml 包含 bugcollect-service → 保留现有 profile 并激活 bugcollect"
fi fi
_set_env "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$_ACTIVE_PROFILES"
export COMPOSE_PROFILES="${_ACTIVE_PROFILES}" export COMPOSE_PROFILES="${_ACTIVE_PROFILES}"
ok "升级使用 profiles=${_ACTIVE_PROFILES}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 1 — 检测 CONSOLE_DOMAIN 是否为裸 IP,提示更正 # Step 1 — 检测 CONSOLE_DOMAIN 是否为裸 IP,提示更正
@ -270,25 +301,10 @@ fi
[ "$_FIXED" -eq 0 ] && ok "SDK 配置无需修复" [ "$_FIXED" -eq 0 ] && ok "SDK 配置无需修复"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 3 — 同步 SDK 初始化配置文件 # Step 3 — 通过唯一渲染器同步 SDK、文档和部署状态配置
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [ -f "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" ]; then bash "$ROOT_DIR/scripts/render-config.sh"
python3 - "$ROOT_DIR/config/sdk/xuqm-private-sdk.json" \ ok "SDK、文档和部署状态配置已由统一渲染器同步"
"$_CONSOLE_DOMAIN" "$_SDK_IM_WS_URL" <<'PY'
import json, sys
path, console, ws = sys.argv[1], sys.argv[2], sys.argv[3]
try:
d = json.load(open(path))
d['controlBaseUrl'] = console
d['fileBaseUrl'] = console
d['imApiBaseUrl'] = console
d['imWsUrl'] = ws
json.dump(d, open(path, 'w'), ensure_ascii=False, indent=2)
except Exception as e:
print(f'warning: {e}', file=sys.stderr)
PY
ok "config/sdk/xuqm-private-sdk.json 已同步"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 4 — 检测并修复宿主机 nginx WebSocket 代理头 # Step 4 — 检测并修复宿主机 nginx WebSocket 代理头

查看文件

@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
TARGET_VERSION="${1:-}"
[ -n "$TARGET_VERSION" ] || fail_json "XUQM_PRIVATE_4020" "usage: upgrade.sh <target-version>" "upgrade"
CURRENT_VERSION="$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')"
audit "upgrade" "STARTED" "from=$CURRENT_VERSION to=$TARGET_VERSION"
progress "upgrade" "STARTED" "from=$CURRENT_VERSION to=$TARGET_VERSION"
printf 'Upgrading from %s to %s\n' "$CURRENT_VERSION" "$TARGET_VERSION"
# Pre-upgrade backup
"$ROOT_DIR/scripts/backup.sh"
audit "upgrade" "BACKUP_DONE" ""
# Validate target version exists in manifest (if present)
MANIFEST="$ROOT_DIR/image-manifest.json"
if command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST" ]; then
MANIFEST_VER="$(python3 -c "import json,sys; d=json.load(open('$MANIFEST')); print(d.get('privateVersion',''))" 2>/dev/null || true)"
if [ -n "$MANIFEST_VER" ] && [ "$MANIFEST_VER" != "$TARGET_VERSION" ]; then
printf 'WARNING: image-manifest.json declares version %s but upgrading to %s\n' "$MANIFEST_VER" "$TARGET_VERSION"
printf 'Update image-manifest.json before proceeding (y/N): '
read -r CONFIRM
[ "$CONFIRM" = "y" ] || fail_json "XUQM_PRIVATE_4021" "upgrade aborted: version mismatch in image-manifest.json" "upgrade"
fi
fi
# Save old image tag for rollback
OLD_TAG="${IMAGE_TAG:-$CURRENT_VERSION}"
set_env_value "$ROOT_DIR/.deploy-state/current.json.bak" "IMAGE_TAG" "$OLD_TAG" 2>/dev/null || true
printf '%s\n' "$OLD_TAG" > "$ROOT_DIR/.deploy-state/previous-image-tag.txt"
# Update image tag
set_env_value "$ROOT_DIR/.env" "IMAGE_TAG" "$TARGET_VERSION"
set_env_value "$ROOT_DIR/.env" "PRIVATE_VERSION" "$TARGET_VERSION"
load_env
# Pull new images for active profiles
PROFILES="${COMPOSE_PROFILES:-base}"
audit "upgrade" "PULL_STARTED" "profiles=$PROFILES"
COMPOSE_PROFILES="$PROFILES" compose pull
audit "upgrade" "PULL_DONE" ""
# Render new configs
"$ROOT_DIR/scripts/render-config.sh"
# Rolling restart — stop then start services
COMPOSE_PROFILES="$PROFILES" compose up -d --remove-orphans
# Health check — rollback automatically on failure
if ! "$ROOT_DIR/scripts/healthcheck.sh"; then
printf 'Health check failed after upgrade. Rolling back...\n'
audit "upgrade" "HEALTHCHECK_FAILED" "rolling back to $OLD_TAG"
set_env_value "$ROOT_DIR/.env" "IMAGE_TAG" "$OLD_TAG"
set_env_value "$ROOT_DIR/.env" "PRIVATE_VERSION" "$OLD_TAG"
load_env
COMPOSE_PROFILES="$PROFILES" compose up -d --remove-orphans
audit "upgrade" "ROLLED_BACK" "restored to $OLD_TAG"
fail_json "XUQM_PRIVATE_4022" "upgrade failed; automatically rolled back to $OLD_TAG" "upgrade"
fi
# Update VERSION file
printf '%s\n' "$TARGET_VERSION" > "$ROOT_DIR/VERSION"
audit "upgrade" "DONE" "from=$CURRENT_VERSION to=$TARGET_VERSION"
progress "upgrade" "DONE" "from=$CURRENT_VERSION to=$TARGET_VERSION"
printf 'Upgrade complete: %s → %s\n' "$CURRENT_VERSION" "$TARGET_VERSION"

查看文件

@ -1,6 +1,6 @@
PRIVATE_VERSION=2026.05.18-private.1 PRIVATE_VERSION=2026.07.28-private.1
REGISTRY=registry.example.com/xuqm REGISTRY=registry.example.com/xuqm
IMAGE_TAG=2026.05.18-private.1 IMAGE_TAG=latest
COMPOSE_PROFILES=base COMPOSE_PROFILES=base
MYSQL_MODE=external MYSQL_MODE=external

查看文件

@ -1,6 +1,6 @@
PRIVATE_VERSION=2026.05.18-private.1 PRIVATE_VERSION=2026.07.28-private.1
REGISTRY=registry.example.com/xuqm REGISTRY=registry.example.com/xuqm
IMAGE_TAG=2026.05.18-private.1 IMAGE_TAG=latest
COMPOSE_PROFILES=base COMPOSE_PROFILES=base
MYSQL_MODE=managed MYSQL_MODE=managed

查看文件

@ -111,6 +111,11 @@ tar -xzf "$TMP_PKG" -C "$INSTALL_DIR" --strip-components=1 --exclude='*/data'
chmod +x "$INSTALL_DIR/scripts/"*.sh \ chmod +x "$INSTALL_DIR/scripts/"*.sh \
"$INSTALL_DIR/install.sh" \ "$INSTALL_DIR/install.sh" \
"$INSTALL_DIR/upgrade.sh" 2>/dev/null || true "$INSTALL_DIR/upgrade.sh" 2>/dev/null || true
# tar 解压只覆盖同名文件,不会删除新版已经移除的历史入口。
rm -f "$INSTALL_DIR/scripts/configure.sh" \
"$INSTALL_DIR/scripts/install.sh" \
"$INSTALL_DIR/scripts/upgrade.sh"
find "$INSTALL_DIR/data" "$INSTALL_DIR/logs" -type f -name '.gitkeep' -delete 2>/dev/null || true
ok "脚本和配置模板已更新" ok "脚本和配置模板已更新"
for _cf in .env config/secrets.env config/xuqm.env; do for _cf in .env config/secrets.env config/xuqm.env; do