feat: implement complete private deployment scripts (P1-P4)

- upgrade.sh/rollback.sh: backup→pull→rolling restart→healthcheck→auto-rollback
- backup.sh/restore.sh: mysqldump+redis BGSAVE+config tar, SHA256 manifest, restore with checksum verification
- healthcheck.sh: Docker/container/MySQL/Redis/HTTP/disk checks, JSON output to .deploy-state/
- doctor.sh: sanitized diagnostics archive, vendor API TCP connectivity, cert expiry
- export-offline-bundle.sh: docker pull+save for all profile images, load-images.sh, SHA256
- configure.sh: interactive/non-interactive mode, MySQL/Redis mode selection, domain prompts
- enable-service.sh: domain validation, docker pull + compose up, healthcheck
- disable-service.sh: compose stop+rm, profile removal, render-config
- renew-cert.sh: acme.sh/certbot, --dry-run, backup old cert, nginx reload on success
- alert-webhook.sh: WeCom/DingTalk/Feishu webhook, message sanitization
- bench.sh: ab/wrk/curl benchmark, JSON report with docker stats
- rotate-secrets.sh: JWT and internal token rotation
- vendor credential templates: push.env and store-submit.env with full credential comments
- render-config.sh: auto-sync SDK URL env vars (SDK_FILE_SERVICE_URL, SDK_IM_API_URL, SDK_IM_WS_URL)
- All scripts pass bash -n syntax check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
徐勤民 2026-05-18 20:49:25 +08:00
父节点 4ada03183a
当前提交 9eabe0d699
共有 19 个文件被更改,包括 1147 次插入59 次删除

查看文件

@ -1,4 +1,5 @@
TENANT_BOOTSTRAP_EMAIL=admin@customer.com
TENANT_BOOTSTRAP_USERNAME=admin
TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login
TENANT_BOOTSTRAP_APP_KEY=ak_private_default

查看文件

@ -1,7 +1,41 @@
# ========== Huawei Push (HMS) ==========
HUAWEI_PUSH_ENABLED=false
MI_PUSH_ENABLED=false
OPPO_PUSH_ENABLED=false
VIVO_PUSH_ENABLED=false
HONOR_PUSH_ENABLED=false
APNS_ENABLED=false
# HUAWEI_PUSH_APP_ID=
# HUAWEI_PUSH_APP_SECRET=
# HUAWEI_PUSH_CLIENT_ID=
# HUAWEI_PUSH_CLIENT_SECRET=
# ========== Xiaomi Push ==========
MI_PUSH_ENABLED=false
# MI_PUSH_APP_SECRET=
# MI_PUSH_PACKAGE_NAME=
# ========== OPPO Push ==========
OPPO_PUSH_ENABLED=false
# OPPO_PUSH_APP_KEY=
# OPPO_PUSH_MASTER_SECRET=
# ========== vivo Push ==========
VIVO_PUSH_ENABLED=false
# VIVO_PUSH_APP_ID=
# VIVO_PUSH_APP_KEY=
# VIVO_PUSH_APP_SECRET=
# ========== Honor Push ==========
HONOR_PUSH_ENABLED=false
# HONOR_PUSH_APP_ID=
# HONOR_PUSH_CLIENT_ID=
# HONOR_PUSH_CLIENT_SECRET=
# ========== APNs (Apple Push) ==========
APNS_ENABLED=false
# APNS_KEY_ID=
# APNS_TEAM_ID=
# APNS_BUNDLE_ID=
# APNS_KEY_FILE=/config/vendors/apns-key.p8
# APNS_PRODUCTION=false
# ========== FCM (Firebase Cloud Messaging — optional) ==========
FCM_ENABLED=false
# FCM_SERVER_KEY=
# FCM_PROJECT_ID=

查看文件

@ -1,6 +1,28 @@
# ========== Huawei AppGallery Connect ==========
HUAWEI_STORE_ENABLED=false
MI_STORE_ENABLED=false
OPPO_STORE_ENABLED=false
VIVO_STORE_ENABLED=false
HONOR_STORE_ENABLED=false
# HUAWEI_STORE_CLIENT_ID=
# HUAWEI_STORE_CLIENT_SECRET=
# HUAWEI_STORE_APP_ID=
# ========== Xiaomi Developer Platform ==========
MI_STORE_ENABLED=false
# MI_STORE_USERNAME=
# MI_STORE_PASSWORD=
# MI_STORE_APP_ID=
# ========== OPPO Open Platform ==========
OPPO_STORE_ENABLED=false
# OPPO_STORE_CLIENT_ID=
# OPPO_STORE_CLIENT_SECRET=
# OPPO_STORE_PKG_NAME=
# ========== vivo Open Platform ==========
VIVO_STORE_ENABLED=false
# VIVO_STORE_ACCESS_KEY=
# VIVO_STORE_ACCESS_SECRET=
# ========== Honor Developer Center ==========
HONOR_STORE_ENABLED=false
# HONOR_STORE_CLIENT_ID=
# HONOR_STORE_CLIENT_SECRET=
# HONOR_STORE_APP_ID=

查看文件

@ -26,3 +26,8 @@ UPDATE_DOMAIN=https://update.customer.com
LICENSE_DOMAIN=https://license.customer.com
PUSH_DOMAIN=https://push.customer.com
# Internal service URLs (used by SDK config endpoint)
SDK_FILE_SERVICE_URL=https://file.customer.com
SDK_IM_API_URL=https://im.customer.com
SDK_IM_WS_URL=wss://im.customer.com/ws/im

71
scripts/alert-webhook.sh 可执行文件
查看文件

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
EVENT="${1:-UNKNOWN}"
MESSAGE="${2:-}"
TIMESTAMP="$(now)"
# Webhook URL from config (never logged)
WEBHOOK_URL="${ALERT_WEBHOOK_URL:-}"
WEBHOOK_TYPE="${ALERT_WEBHOOK_TYPE:-wecom}" # wecom | dingtalk | feishu
[ -n "$WEBHOOK_URL" ] || {
printf 'ALERT_WEBHOOK_URL not configured; alert suppressed: [%s] %s\n' "$EVENT" "$MESSAGE" >&2
exit 0
}
# Sanitize message: strip any passwords, tokens, keys
SAFE_MSG="$(printf '%s' "$MESSAGE" | sed 's/password=[^ ]*/password=***/gi; s/token=[^ ]*/token=***/gi; s/secret=[^ ]*/secret=***/gi')"
build_payload() {
local event="$1"
local msg="$2"
local ts="$3"
local version="${PRIVATE_VERSION:-unknown}"
case "$WEBHOOK_TYPE" in
dingtalk)
printf '{"msgtype":"markdown","markdown":{"title":"[%s] XuqmGroup Private","text":"**[%s]** %s\n\n版本: %s\n时间: %s"}}' \
"$event" "$event" "$msg" "$version" "$ts"
;;
feishu)
printf '{"msg_type":"text","content":{"text":"[%s] %s\n版本: %s\n时间: %s"}}' \
"$event" "$msg" "$version" "$ts"
;;
*)
# WeCom (企业微信)
printf '{"msgtype":"markdown","markdown":{"content":"**[%s]** %s\n>版本: `%s`\n>时间: %s"}}' \
"$event" "$msg" "$version" "$ts"
;;
esac
}
PAYLOAD="$(build_payload "$EVENT" "$SAFE_MSG" "$TIMESTAMP")"
if command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' \
-X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" \
--max-time 10 \
"$WEBHOOK_URL" 2>/dev/null || echo '000')"
if [ "$HTTP_CODE" = "200" ]; then
audit "alert" "SENT" "event=$EVENT"
else
audit "alert" "FAILED" "event=$EVENT http=$HTTP_CODE"
fi
else
audit "alert" "SKIPPED" "curl not available event=$EVENT"
fi
# Consecutive failure tracking for auto-alert on healthcheck failures
FAIL_COUNT_FILE="$ROOT_DIR/.deploy-state/healthcheck-fail-count.txt"
if [ "$EVENT" = "HEALTHCHECK_FAILED" ]; then
COUNT="$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
COUNT=$((COUNT + 1))
printf '%s\n' "$COUNT" > "$FAIL_COUNT_FILE"
else
printf '0\n' > "$FAIL_COUNT_FILE"
fi

查看文件

@ -3,14 +3,99 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
audit "backup" "STARTED" "creating config backup"
progress "backup" "STARTED" "creating config backup"
BACKUP_DIR="$ROOT_DIR/data/backups"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
BACKUP_NAME="xuqm-backup-${PRIVATE_VERSION:-unknown}-${TIMESTAMP}"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_NAME"
mkdir -p "$ROOT_DIR/data/backups"
tar --exclude='config/secrets.env' -czf "$ROOT_DIR/data/backups/config-$(date +%Y%m%d%H%M%S).tar.gz" \
audit "backup" "STARTED" "backup=$BACKUP_NAME"
progress "backup" "STARTED" "backup=$BACKUP_NAME"
mkdir -p "$BACKUP_PATH"
# Config backup (excluding secrets.env for security)
tar --exclude='config/secrets.env' -czf "$BACKUP_PATH/config.tar.gz" \
-C "$ROOT_DIR" VERSION .env config .deploy-state
audit "backup" "CONFIG_DONE" "config.tar.gz"
audit "backup" "DONE" "backup created"
progress "backup" "DONE" "backup created"
# MySQL backup
if [ "${MYSQL_MODE:-external}" = "managed" ]; then
MYSQL_CTR="$(compose ps -q mysql 2>/dev/null | head -1 || true)"
if [ -n "$MYSQL_CTR" ]; then
docker exec "$MYSQL_CTR" \
mysqldump -u root -p"${MYSQL_ROOT_PASSWORD:-}" \
--single-transaction --routines --triggers --events \
"${MYSQL_DATABASE:-xuqm_private}" \
> "$BACKUP_PATH/mysql.sql"
audit "backup" "MYSQL_DONE" "mysqldump from managed container"
else
audit "backup" "MYSQL_SKIPPED" "managed mysql container not running"
printf '# managed MySQL container not running at backup time\n' > "$BACKUP_PATH/mysql.sql"
fi
elif [ -n "${MYSQL_HOST:-}" ] && [ "${MYSQL_HOST:-}" != "127.0.0.1" ]; then
if command -v mysqldump >/dev/null 2>&1; then
mysqldump -h "${MYSQL_HOST}" -P "${MYSQL_PORT:-3306}" \
-u "${MYSQL_USERNAME:-}" -p"${MYSQL_PASSWORD:-}" \
--single-transaction --routines --triggers --events \
"${MYSQL_DATABASE:-xuqm_private}" \
> "$BACKUP_PATH/mysql.sql"
audit "backup" "MYSQL_DONE" "mysqldump from external host"
else
printf '# mysqldump not available on this host. Run manually:\n' > "$BACKUP_PATH/mysql.sql"
printf '# mysqldump -h %s -P %s -u %s -p <password> --single-transaction %s > mysql.sql\n' \
"${MYSQL_HOST}" "${MYSQL_PORT:-3306}" "${MYSQL_USERNAME:-}" "${MYSQL_DATABASE:-}" >> "$BACKUP_PATH/mysql.sql"
audit "backup" "MYSQL_SKIPPED" "mysqldump not available; manual instructions written"
fi
else
audit "backup" "MYSQL_SKIPPED" "external mode with localhost — run mysqldump manually"
printf '# External MySQL on localhost. Run mysqldump manually.\n' > "$BACKUP_PATH/mysql.sql"
fi
# Redis backup
if [ "${REDIS_MODE:-external}" = "managed" ]; then
REDIS_CTR="$(compose ps -q redis 2>/dev/null | head -1 || true)"
if [ -n "$REDIS_CTR" ]; then
docker exec "$REDIS_CTR" redis-cli -a "${REDIS_PASSWORD:-}" BGSAVE 2>/dev/null || true
sleep 2
docker cp "$REDIS_CTR:/data/dump.rdb" "$BACKUP_PATH/redis-dump.rdb" 2>/dev/null || true
audit "backup" "REDIS_DONE" "bgsave + copy from managed container"
else
audit "backup" "REDIS_SKIPPED" "managed redis container not running"
fi
else
if command -v redis-cli >/dev/null 2>&1 && [ -n "${REDIS_HOST:-}" ]; then
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT:-6379}" -a "${REDIS_PASSWORD:-}" \
--no-auth-warning BGSAVE 2>/dev/null || true
audit "backup" "REDIS_TRIGGERED" "BGSAVE triggered on external Redis"
else
audit "backup" "REDIS_SKIPPED" "redis-cli not available"
fi
fi
# Write manifest
MANIFEST="$BACKUP_PATH/manifest.json"
cat > "$MANIFEST" <<EOF
{
"backupName": "$BACKUP_NAME",
"timestamp": "$(now)",
"privateVersion": "${PRIVATE_VERSION:-unknown}",
"mysqlMode": "${MYSQL_MODE:-external}",
"redisMode": "${REDIS_MODE:-external}",
"profiles": "${COMPOSE_PROFILES:-base}",
"files": {
"config": "config.tar.gz",
"mysql": "mysql.sql",
"redis": "redis-dump.rdb"
},
"restoreNotes": "Run: ./scripts/restore.sh $BACKUP_NAME"
}
EOF
# Compute checksums
(cd "$BACKUP_DIR" && find "$BACKUP_NAME" -type f -exec sha256sum {} \; > "$BACKUP_PATH/sha256sums.txt")
audit "backup" "DONE" "backup=$BACKUP_NAME path=$BACKUP_PATH"
progress "backup" "DONE" "backup=$BACKUP_NAME"
printf 'Backup complete: %s\n' "$BACKUP_PATH"

94
scripts/bench.sh 可执行文件
查看文件

@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
PROFILE="${1:-base}"
DURATION="${2:-30}"
REPORT_FILE="$ROOT_DIR/dist/bench-$(date +%Y%m%d%H%M%S).json"
mkdir -p "$ROOT_DIR/dist"
audit "bench" "STARTED" "profile=$PROFILE duration=${DURATION}s"
progress "bench" "STARTED" "profile=$PROFILE"
require_cmd curl
require_cmd docker
BASE_URL="${CONSOLE_DOMAIN:-http://localhost}"
bench_http() {
local name="$1"
local url="$2"
local concurrency="${3:-10}"
local requests="${4:-100}"
local pass_rps="${5:-50}"
local pass_p99_ms="${6:-500}"
printf 'Benchmarking %s (%s)...\n' "$name" "$url"
if command -v ab >/dev/null 2>&1; then
RAW="$(ab -n "$requests" -c "$concurrency" -q "$url" 2>/dev/null || true)"
RPS="$(printf '%s' "$RAW" | grep 'Requests per second' | awk '{print $4}' | cut -d. -f1)"
P99="$(printf '%s' "$RAW" | grep '99%' | awk '{print $2}')"
RPS="${RPS:-0}"
P99="${P99:-9999}"
elif command -v wrk >/dev/null 2>&1; then
RAW="$(wrk -t2 -c"$concurrency" -d"${DURATION}s" "$url" 2>/dev/null || true)"
RPS="$(printf '%s' "$RAW" | grep 'Requests/sec' | awk '{print $2}' | cut -d. -f1)"
P99="${P99:-0}"
RPS="${RPS:-0}"
else
# Fallback: sequential curl timing
total_ms=0
for _ in $(seq 1 20); do
ms="$(curl -sk -o /dev/null -w '%{time_total}' --max-time 5 "$url" 2>/dev/null | awk '{printf "%d", $1*1000}')"
total_ms=$((total_ms + ms))
done
AVG_MS=$((total_ms / 20))
RPS=$((1000 / (AVG_MS + 1)))
P99="$AVG_MS"
fi
PASS="true"
[ "$RPS" -lt "$pass_rps" ] && PASS="false"
[ "$P99" -gt "$pass_p99_ms" ] && PASS="false"
printf ' RPS: %s (min %s) | P99: %sms (max %sms) | %s\n' \
"$RPS" "$pass_rps" "$P99" "$pass_p99_ms" "$( [ "$PASS" = "true" ] && printf 'PASS' || printf 'FAIL')"
printf '{"name":"%s","url":"%s","rps":%s,"p99_ms":%s,"target_rps":%s,"target_p99_ms":%s,"pass":%s}' \
"$name" "$url" "$RPS" "$P99" "$pass_rps" "$pass_p99_ms" "$PASS"
}
RESULTS=()
RESULTS+=("$(bench_http "tenant-api-health" "$BASE_URL/actuator/health" 5 50 20 1000)")
if printf '%s' "$PROFILE" | grep -q 'im' && [ -n "${IM_DOMAIN:-}" ]; then
RESULTS+=("$(bench_http "im-api-health" "${IM_DOMAIN}/actuator/health" 5 50 20 1000)")
fi
if printf '%s' "$PROFILE" | grep -q 'update' && [ -n "${UPDATE_DOMAIN:-}" ]; then
RESULTS+=("$(bench_http "update-api-health" "${UPDATE_DOMAIN}/actuator/health" 5 50 20 1000)")
fi
# Docker stats snapshot
DOCKER_STATS="$(docker stats --no-stream --format '{"container":"{{.Name}}","cpu":"{{.CPUPerc}}","mem":"{{.MemUsage}}"}' 2>/dev/null || echo '[]')"
RESULTS_JSON="$(printf '%s\n' "${RESULTS[@]}" | paste -sd ',' - | sed 's/^/[/' | sed 's/$/]/')"
cat > "$REPORT_FILE" <<EOF
{
"timestamp": "$(now)",
"profile": "$PROFILE",
"durationSec": $DURATION,
"privateVersion": "${PRIVATE_VERSION:-unknown}",
"results": $RESULTS_JSON,
"dockerStats": $DOCKER_STATS
}
EOF
audit "bench" "DONE" "report=$REPORT_FILE"
progress "bench" "DONE" "profile=$PROFILE"
printf 'Benchmark report: %s\n' "$REPORT_FILE"

查看文件

@ -4,16 +4,148 @@ 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}"
ENABLE_LICENSE="${ENABLE_LICENSE:-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 license\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
if printf '%s' "${OPT_SVCS:-}" | grep -q 'license'; then ENABLE_LICENSE=true; LICENSE_DOMAIN="$(ask "License domain" "${LICENSE_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 (idempotent — only update values that changed)
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"
[ -n "${LICENSE_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/.env" "LICENSE_DOMAIN" "$LICENSE_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" "ENABLE_LICENSE" "$ENABLE_LICENSE"
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"
# 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"
"$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'

查看文件

@ -5,7 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
SERVICE="${1:-}"
[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required" "disable-service"
[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required (im|push|update|license)" "disable-service"
if [ ! -f "$ROOT_DIR/.env" ]; then
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
@ -16,15 +16,22 @@ audit "disable-service" "STARTED" "$SERVICE"
progress "disable-service" "STARTED" "$SERVICE"
case "$SERVICE" in
im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "false" ;;
push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "false" ;;
update) set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "false" ;;
license) set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "false" ;;
*) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE" "disable-service" ;;
im|push|update|license) ;;
*) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE (valid: im push update license)" "disable-service" ;;
esac
set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$(remove_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")"
# Stop the container first (data is preserved)
compose stop "${SERVICE}-service" 2>/dev/null || true
compose rm -f "${SERVICE}-service" 2>/dev/null || true
# Update feature flag and profile
set_env_value "$ROOT_DIR/.env" "ENABLE_$(printf '%s' "$SERVICE" | tr '[:lower:]' '[:upper:]')" "false"
NEW_PROFILES="$(remove_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")"
set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$NEW_PROFILES"
load_env
"$ROOT_DIR/scripts/render-config.sh"
audit "disable-service" "DONE" "$SERVICE"
audit "disable-service" "DONE" "$SERVICE profiles=$NEW_PROFILES"
progress "disable-service" "DONE" "$SERVICE"
printf 'Service disabled: %s\nActive profiles: %s\nNote: data is preserved.\n' "$SERVICE" "$NEW_PROFILES"

查看文件

@ -3,15 +3,111 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
DIAG_DIR="$ROOT_DIR/dist/doctor-${TIMESTAMP}"
DIAG_ARCHIVE="$ROOT_DIR/dist/doctor-${TIMESTAMP}.tar.gz"
audit "doctor" "STARTED" "collecting diagnostics"
progress "doctor" "STARTED" "collecting diagnostics"
mkdir -p "$ROOT_DIR/dist"
tar --exclude='config/secrets.env' --exclude='data' --exclude='*.tar.gz' \
-czf "$ROOT_DIR/dist/doctor-$(date +%Y%m%d%H%M%S).tar.gz" \
-C "$ROOT_DIR" VERSION .deploy-state config logs README.md
mkdir -p "$DIAG_DIR"
audit "doctor" "DONE" "diagnostics collected"
progress "doctor" "DONE" "diagnostics collected"
# System info
{
printf '=== System Info ===\n'
uname -a 2>/dev/null || true
printf '\n=== Docker Version ===\n'
docker version 2>/dev/null || true
printf '\n=== Disk Usage ===\n'
df -h "$ROOT_DIR" 2>/dev/null || true
printf '\n=== Memory ===\n'
free -h 2>/dev/null || vm_stat 2>/dev/null || true
} > "$DIAG_DIR/system.txt"
# Container states
compose ps 2>/dev/null > "$DIAG_DIR/containers.txt" || true
# Last healthcheck (already sanitized — no secrets)
[ -f "$ROOT_DIR/.deploy-state/last-healthcheck.json" ] && \
cp "$ROOT_DIR/.deploy-state/last-healthcheck.json" "$DIAG_DIR/"
# Deploy state
cp "$ROOT_DIR/.deploy-state/progress.md" "$DIAG_DIR/" 2>/dev/null || true
# Sanitized env (strip secrets)
grep -v -E '(PASSWORD|SECRET|TOKEN|KEY|SMTP|AUTH)' "$ROOT_DIR/.env" 2>/dev/null \
> "$DIAG_DIR/env-sanitized.txt" || true
grep -v -E '(PASSWORD|SECRET|TOKEN|KEY|SMTP|AUTH)' "$ROOT_DIR/config/xuqm.env" 2>/dev/null \
>> "$DIAG_DIR/env-sanitized.txt" || true
# Recent audit log (last 200 lines)
tail -200 "$ROOT_DIR/logs/audit.log" 2>/dev/null > "$DIAG_DIR/audit-recent.log" || true
# Recent container logs (last 100 lines per service, sanitized)
for svc in tenant-service file-service nginx im-service push-service update-service license-service; do
CTR="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)"
if [ -n "$CTR" ]; then
docker logs --tail=100 "$CTR" 2>&1 | \
grep -v -E '(password|secret|token|Authorization)' \
> "$DIAG_DIR/log-${svc}.txt" 2>/dev/null || true
fi
done
# Vendor API connectivity checks (DNS + TCP only, no credentials sent)
{
printf '=== Vendor API Connectivity ===\n'
vendor_check() {
local name="$1"
local host="$2"
local port="${3:-443}"
printf '\n%s (%s:%s): ' "$name" "$host" "$port"
if command -v nc >/dev/null 2>&1; then
if nc -z -w5 "$host" "$port" 2>/dev/null; then
printf 'TCP OK\n'
else
printf 'TCP FAIL\n'
fi
elif command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 5 "https://$host" 2>/dev/null || echo '000')"
printf 'HTTP %s\n' "$HTTP_CODE"
else
printf 'SKIPPED (no nc/curl)\n'
fi
}
if [ "${ENABLE_PUSH:-false}" = "true" ]; then
vendor_check "Huawei Push" "push-api.cloud.huawei.com" 443
vendor_check "Xiaomi Push" "api.xmpush.xiaomi.com" 443
vendor_check "OPPO Push" "api.push.oppomobile.com" 443
vendor_check "vivo Push" "api-push.vivo.com.cn" 443
vendor_check "APNs" "api.push.apple.com" 443
fi
if [ "${ENABLE_UPDATE:-false}" = "true" ]; then
vendor_check "Huawei AppGallery" "connect-api.cloud.huawei.com" 443
vendor_check "Xiaomi Store" "api.developer.xiaomi.com" 443
vendor_check "OPPO Store" "oop-openapi.oppomobile.com" 443
vendor_check "vivo Store" "developer.vivo.com.cn" 443
vendor_check "Honor Store" "developer.hihonor.com" 443
fi
} > "$DIAG_DIR/vendor-connectivity.txt" 2>&1
# Certificate expiry check
if [ -n "${CONSOLE_DOMAIN:-}" ] && command -v openssl >/dev/null 2>&1; then
HOST="$(printf '%s' "${CONSOLE_DOMAIN#https://}" | cut -d'/' -f1)"
{
printf '=== TLS Certificate Expiry ===\n'
printf '%s\n' "$HOST"
openssl s_client -connect "${HOST}:443" -servername "$HOST" </dev/null 2>/dev/null | \
openssl x509 -noout -dates 2>/dev/null || printf 'Could not check certificate\n'
} > "$DIAG_DIR/cert-expiry.txt" 2>&1
fi
# Package into archive
tar -czf "$DIAG_ARCHIVE" -C "$ROOT_DIR/dist" "doctor-${TIMESTAMP}"
rm -rf "$DIAG_DIR"
audit "doctor" "DONE" "archive=$DIAG_ARCHIVE"
progress "doctor" "DONE" "archive=$DIAG_ARCHIVE"
printf 'Diagnostics collected: %s\n' "$DIAG_ARCHIVE"

查看文件

@ -5,7 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
SERVICE="${1:-}"
[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required" "enable-service"
[ -n "$SERVICE" ] || fail_json "XUQM_PRIVATE_1002" "service name is required (im|push|update|license)" "enable-service"
if [ ! -f "$ROOT_DIR/.env" ]; then
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
@ -16,15 +16,39 @@ audit "enable-service" "STARTED" "$SERVICE"
progress "enable-service" "STARTED" "$SERVICE"
case "$SERVICE" in
im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "true" ;;
push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "true" ;;
update) set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "true" ;;
license) set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "true" ;;
*) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE" "enable-service" ;;
im)
set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "true"
[ -n "${IM_DOMAIN:-}" ] || fail_json "XUQM_PRIVATE_1003" "IM_DOMAIN must be set before enabling im" "enable-service"
;;
push)
set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "true"
[ -n "${PUSH_DOMAIN:-}" ] || fail_json "XUQM_PRIVATE_1003" "PUSH_DOMAIN must be set before enabling push" "enable-service"
;;
update)
set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "true"
[ -n "${UPDATE_DOMAIN:-}" ] || fail_json "XUQM_PRIVATE_1003" "UPDATE_DOMAIN must be set before enabling update" "enable-service"
;;
license)
set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "true"
[ -n "${LICENSE_DOMAIN:-}" ] || fail_json "XUQM_PRIVATE_1003" "LICENSE_DOMAIN must be set before enabling license" "enable-service"
;;
*)
fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE (valid: im push update license)" "enable-service"
;;
esac
set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$(add_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")"
NEW_PROFILES="$(add_profile "${COMPOSE_PROFILES:-base}" "$SERVICE")"
set_env_value "$ROOT_DIR/.env" "COMPOSE_PROFILES" "$NEW_PROFILES"
load_env
"$ROOT_DIR/scripts/render-config.sh"
audit "enable-service" "DONE" "$SERVICE"
# Pull and start the new service
COMPOSE_PROFILES="$NEW_PROFILES" compose pull "$SERVICE-service" 2>/dev/null || true
COMPOSE_PROFILES="$NEW_PROFILES" compose up -d "$SERVICE-service"
"$ROOT_DIR/scripts/healthcheck.sh"
audit "enable-service" "DONE" "$SERVICE profiles=$NEW_PROFILES"
progress "enable-service" "DONE" "$SERVICE"
printf 'Service enabled: %s\nActive profiles: %s\n' "$SERVICE" "$NEW_PROFILES"

查看文件

@ -3,15 +3,91 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
OUT_DIR="${1:-$ROOT_DIR/dist}"
mkdir -p "$OUT_DIR"
BUNDLE_NAME="xuqm-private-$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')-offline"
BUNDLE_DIR="$OUT_DIR/$BUNDLE_NAME"
BUNDLE_ARCHIVE="$OUT_DIR/${BUNDLE_NAME}.tar.gz"
audit "export-offline-bundle" "STARTED" "$OUT_DIR"
progress "export-offline-bundle" "STARTED" "$OUT_DIR"
tar --exclude='data' --exclude='dist' --exclude='.git' -czf "$OUT_DIR/xuqm-private-$(cat "$ROOT_DIR/VERSION").tar.gz" -C "$ROOT_DIR" .
mkdir -p "$BUNDLE_DIR"
audit "export-offline-bundle" "DONE" "$OUT_DIR"
progress "export-offline-bundle" "DONE" "$OUT_DIR"
# Copy deploy repo (exclude data, dist, git, secrets)
tar --exclude='data' --exclude='dist' --exclude='.git' \
--exclude='config/secrets.env' --exclude='logs/audit.log' \
-czf "$BUNDLE_DIR/deploy-repo.tar.gz" -C "$ROOT_DIR" .
# Pull and save Docker images
IMAGES_DIR="$BUNDLE_DIR/images"
mkdir -p "$IMAGES_DIR"
REGISTRY="${REGISTRY:-registry.example.com/xuqm}"
TAG="${IMAGE_TAG:-$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')}"
PROFILES="${COMPOSE_PROFILES:-base}"
pull_and_save() {
local svc="$1"
local image="${REGISTRY}/${svc}:${TAG}"
local out_file="$IMAGES_DIR/${svc}.tar"
printf 'Pulling %s ...\n' "$image"
if docker pull "$image" 2>/dev/null; then
docker save "$image" -o "$out_file"
printf 'Saved %s → %s\n' "$image" "$out_file"
else
printf 'WARNING: could not pull %s (skip in offline bundle)\n' "$image"
fi
}
# Always include base images
for svc in tenant-service file-service tenant-web ops-web docs-site; do
pull_and_save "$svc"
done
# Optional services
profile_contains() { case ",${PROFILES}," in *","$1","*) return 0;; esac; return 1; }
profile_contains "im" && pull_and_save "im-service"
profile_contains "push" && pull_and_save "push-service"
profile_contains "update" && pull_and_save "update-service"
profile_contains "license" && pull_and_save "license-service"
# Infrastructure images (always include for managed mode)
docker pull nginx:1.27-alpine 2>/dev/null && docker save nginx:1.27-alpine -o "$IMAGES_DIR/nginx.tar" || true
docker pull mysql:8.4 2>/dev/null && docker save mysql:8.4 -o "$IMAGES_DIR/mysql.tar" || true
docker pull redis:7.4-alpine 2>/dev/null && docker save redis:7.4-alpine -o "$IMAGES_DIR/redis.tar" || true
# Generate image load script for offline use
cat > "$BUNDLE_DIR/load-images.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for img in "$SCRIPT_DIR/images/"*.tar; do
[ -f "$img" ] || continue
printf 'Loading %s...\n' "$img"
docker load -i "$img"
done
printf 'All images loaded.\n'
SCRIPT
chmod +x "$BUNDLE_DIR/load-images.sh"
# Copy manifest
cp "$ROOT_DIR/image-manifest.json" "$BUNDLE_DIR/" 2>/dev/null || true
# Checksums for all files in bundle
(cd "$OUT_DIR" && find "$BUNDLE_NAME" -type f -exec sha256sum {} \; > "$BUNDLE_DIR/sha256sums.txt")
# Package
tar -czf "$BUNDLE_ARCHIVE" -C "$OUT_DIR" "$BUNDLE_NAME"
rm -rf "$BUNDLE_DIR"
# Checksum of the final archive
sha256sum "$BUNDLE_ARCHIVE" > "${BUNDLE_ARCHIVE}.sha256"
audit "export-offline-bundle" "DONE" "archive=$BUNDLE_ARCHIVE"
progress "export-offline-bundle" "DONE" "archive=$BUNDLE_ARCHIVE"
printf 'Offline bundle: %s\n' "$BUNDLE_ARCHIVE"
printf 'SHA256: %s\n' "$(cat "${BUNDLE_ARCHIVE}.sha256")"

查看文件

@ -8,27 +8,168 @@ load_env
audit "healthcheck" "STARTED" "running checks"
progress "healthcheck" "STARTED" "running checks"
require_cmd docker
OVERALL="UP"
RESULTS=()
WARNINGS=()
STATUS="UP"
WARNINGS="[]"
check_pass() { RESULTS+=("{\"check\":\"$1\",\"status\":\"OK\",\"detail\":\"$2\"}"); }
check_fail() { RESULTS+=("{\"check\":\"$1\",\"status\":\"FAIL\",\"detail\":\"$2\"}"); OVERALL="DOWN"; }
check_warn() { RESULTS+=("{\"check\":\"$1\",\"status\":\"WARN\",\"detail\":\"$2\"}"); WARNINGS+=("$1"); }
check_skip() { RESULTS+=("{\"check\":\"$1\",\"status\":\"SKIPPED\",\"detail\":\"$2\"}"); }
if ! docker ps >/dev/null 2>&1; then
STATUS="DOWN"
# Docker daemon
if docker info >/dev/null 2>&1; then
check_pass "docker" "daemon running"
else
check_fail "docker" "daemon not available"
fi
# Container states
for svc in tenant-service file-service tenant-web ops-web docs-site nginx; do
STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)"
if [ -n "$STATE" ]; then
STATUS="$(docker inspect --format='{{.State.Status}}' "$STATE" 2>/dev/null || echo 'unknown')"
if [ "$STATUS" = "running" ]; then
check_pass "container.$svc" "running"
else
check_fail "container.$svc" "status=$STATUS"
fi
else
check_fail "container.$svc" "not found"
fi
done
# Optional service containers
for svc in im-service push-service update-service license-service; do
STATE="$(compose ps -q "$svc" 2>/dev/null | head -1 || true)"
if [ -n "$STATE" ]; then
STATUS="$(docker inspect --format='{{.State.Status}}' "$STATE" 2>/dev/null || echo 'unknown')"
if [ "$STATUS" = "running" ]; then
check_pass "container.$svc" "running"
else
check_fail "container.$svc" "status=$STATUS"
fi
fi
done
# MySQL connectivity
MYSQL_HOST_VAL="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT_VAL="${MYSQL_PORT:-3306}"
if [ "${MYSQL_MODE:-external}" = "managed" ]; then
MYSQL_CTR="$(compose ps -q mysql 2>/dev/null | head -1 || true)"
if [ -n "$MYSQL_CTR" ]; then
if docker exec "$MYSQL_CTR" mysqladmin -u root -p"${MYSQL_ROOT_PASSWORD:-}" ping --silent 2>/dev/null; then
check_pass "mysql" "managed container healthy"
else
check_fail "mysql" "managed container ping failed"
fi
else
check_fail "mysql" "managed container not running"
fi
else
if command -v nc >/dev/null 2>&1; then
if nc -z -w3 "$MYSQL_HOST_VAL" "$MYSQL_PORT_VAL" 2>/dev/null; then
check_pass "mysql" "tcp reachable at $MYSQL_HOST_VAL:$MYSQL_PORT_VAL"
else
check_fail "mysql" "tcp unreachable at $MYSQL_HOST_VAL:$MYSQL_PORT_VAL"
fi
else
check_skip "mysql" "nc not available for TCP check"
fi
fi
# Redis connectivity
REDIS_HOST_VAL="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT_VAL="${REDIS_PORT:-6379}"
if [ "${REDIS_MODE:-external}" = "managed" ]; then
REDIS_CTR="$(compose ps -q redis 2>/dev/null | head -1 || true)"
if [ -n "$REDIS_CTR" ]; then
if docker exec "$REDIS_CTR" redis-cli -a "${REDIS_PASSWORD:-}" --no-auth-warning PING 2>/dev/null | grep -q PONG; then
check_pass "redis" "managed container healthy"
else
check_fail "redis" "managed container ping failed"
fi
else
check_fail "redis" "managed container not running"
fi
else
if command -v nc >/dev/null 2>&1; then
if nc -z -w3 "$REDIS_HOST_VAL" "$REDIS_PORT_VAL" 2>/dev/null; then
check_pass "redis" "tcp reachable at $REDIS_HOST_VAL:$REDIS_PORT_VAL"
else
check_fail "redis" "tcp unreachable at $REDIS_HOST_VAL:$REDIS_PORT_VAL"
fi
else
check_skip "redis" "nc not available for TCP check"
fi
fi
# HTTP health endpoints
http_check() {
local name="$1"
local url="$2"
if command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo '000')"
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then
check_pass "$name" "HTTP $HTTP_CODE from $url"
else
check_warn "$name" "HTTP $HTTP_CODE from $url"
fi
else
check_skip "$name" "curl not available"
fi
}
CONSOLE_DOMAIN_VAL="${CONSOLE_DOMAIN:-}"
[ -n "$CONSOLE_DOMAIN_VAL" ] && http_check "http.tenant-service" "${CONSOLE_DOMAIN_VAL}/actuator/health"
[ -n "$CONSOLE_DOMAIN_VAL" ] && http_check "http.tenant-web" "${CONSOLE_DOMAIN_VAL}/"
[ "${ENABLE_IM:-false}" = "true" ] && [ -n "${IM_DOMAIN:-}" ] && \
http_check "http.im-service" "${IM_DOMAIN}/actuator/health"
[ "${ENABLE_UPDATE:-false}" = "true" ] && [ -n "${UPDATE_DOMAIN:-}" ] && \
http_check "http.update-service" "${UPDATE_DOMAIN}/actuator/health"
[ "${ENABLE_LICENSE:-false}" = "true" ] && [ -n "${LICENSE_DOMAIN:-}" ] && \
http_check "http.license-service" "${LICENSE_DOMAIN}/actuator/health"
# Disk space
DISK_USE="$(df -h "$ROOT_DIR" | awk 'NR==2{print $5}' | tr -d '%')"
if [ -n "$DISK_USE" ]; then
if [ "$DISK_USE" -ge 90 ]; then
check_fail "disk" "usage ${DISK_USE}% >= 90%"
elif [ "$DISK_USE" -ge 85 ]; then
check_warn "disk" "usage ${DISK_USE}% >= 85%"
else
check_pass "disk" "usage ${DISK_USE}%"
fi
fi
# Build JSON result
RESULTS_JSON="$(printf '%s\n' "${RESULTS[@]+"${RESULTS[@]}"}" | paste -sd ',' - | sed 's/^/[/' | sed 's/$/]/')"
WARNINGS_JSON="$(printf '"%s"\n' "${WARNINGS[@]+"${WARNINGS[@]}"}" | paste -sd ',' - | sed 's/^/[/' | sed 's/$/]/')"
[ -z "${WARNINGS[*]:-}" ] && WARNINGS_JSON="[]"
cat > "$ROOT_DIR/.deploy-state/last-healthcheck.json" <<EOF
{
"status": "$STATUS",
"version": "${PRIVATE_VERSION:-2026.05.18-private.1}",
"mysql": {"mode": "${MYSQL_MODE:-external}", "status": "UNKNOWN"},
"redis": {"mode": "${REDIS_MODE:-external}", "status": "UNKNOWN"},
"warnings": $WARNINGS
"status": "$OVERALL",
"timestamp": "$(now)",
"version": "${PRIVATE_VERSION:-unknown}",
"mysqlMode": "${MYSQL_MODE:-external}",
"redisMode": "${REDIS_MODE:-external}",
"profiles": "${COMPOSE_PROFILES:-base}",
"checks": $RESULTS_JSON,
"warnings": $WARNINGS_JSON
}
EOF
audit "healthcheck" "$STATUS" "healthcheck finished"
progress "healthcheck" "$STATUS" "healthcheck finished"
audit "healthcheck" "$OVERALL" "checks=${#RESULTS[@]}"
progress "healthcheck" "$OVERALL" ""
[ "$STATUS" = "UP" ] || fail_json "XUQM_PRIVATE_4001" "docker is not available" "healthcheck"
# Print summary
printf '\n=== Health Check: %s ===\n' "$OVERALL"
for r in "${RESULTS[@]+"${RESULTS[@]}"}"; do
printf ' %s\n' "$r"
done
[ "$OVERALL" = "UP" ] || fail_json "XUQM_PRIVATE_4040" "health check failed; see .deploy-state/last-healthcheck.json" "healthcheck"

查看文件

@ -95,5 +95,10 @@ cat > "$ROOT_DIR/.deploy-state/current.json" <<EOF
}
EOF
# Keep SDK internal URL env vars in sync with domain config
[ -n "${FILE_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/config/xuqm.env" "SDK_FILE_SERVICE_URL" "${FILE_DOMAIN}"
[ -n "${IM_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/config/xuqm.env" "SDK_IM_API_URL" "${IM_DOMAIN}"
[ -n "${IM_DOMAIN:-}" ] && set_env_value "$ROOT_DIR/config/xuqm.env" "SDK_IM_WS_URL" "$(ws_url "${IM_DOMAIN}")"
audit "render-config" "DONE" "runtime files rendered"
progress "render-config" "DONE" "runtime files rendered"

71
scripts/renew-cert.sh 可执行文件
查看文件

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
DRY_RUN=false
[ "${1:-}" = "--dry-run" ] && DRY_RUN=true
CERT_DIR="${CERT_DIR:-$ROOT_DIR/config/nginx/certs}"
DOMAIN="${CONSOLE_DOMAIN:-}"
DOMAIN="${DOMAIN#https://}"
DOMAIN="${DOMAIN%%/*}"
audit "renew-cert" "STARTED" "domain=$DOMAIN dry_run=$DRY_RUN"
progress "renew-cert" "STARTED" "domain=$DOMAIN"
[ -n "$DOMAIN" ] || fail_json "XUQM_PRIVATE_5010" "CONSOLE_DOMAIN not set" "renew-cert"
mkdir -p "$CERT_DIR"
# Backup existing certs
CERT_FILE="$CERT_DIR/${DOMAIN}.crt"
KEY_FILE="$CERT_DIR/${DOMAIN}.key"
if [ -f "$CERT_FILE" ]; then
CERT_BACKUP="${CERT_FILE}.$(date +%Y%m%d%H%M%S).bak"
cp "$CERT_FILE" "$CERT_BACKUP"
cp "$KEY_FILE" "${KEY_FILE}.$(date +%Y%m%d%H%M%S).bak"
audit "renew-cert" "BACKUP_DONE" "backed up to $CERT_BACKUP"
fi
if [ "$DRY_RUN" = "true" ]; then
printf '[DRY-RUN] Would renew certificate for %s\n' "$DOMAIN"
printf '[DRY-RUN] Would reload nginx after renewal\n'
audit "renew-cert" "DRY_RUN" "domain=$DOMAIN"
exit 0
fi
# Try acme.sh first, then certbot
if command -v acme.sh >/dev/null 2>&1; then
acme.sh --renew -d "$DOMAIN" --force 2>&1 | tee -a "$ROOT_DIR/logs/cert-renew.log" || {
audit "renew-cert" "FAILED" "acme.sh renewal failed for $DOMAIN"
"$ROOT_DIR/scripts/alert-webhook.sh" "CERT_RENEW_FAILED" "Certificate renewal failed for $DOMAIN" 2>/dev/null || true
fail_json "XUQM_PRIVATE_5011" "certificate renewal failed for $DOMAIN" "renew-cert"
}
# Copy renewed cert to nginx config dir
acme.sh --install-cert -d "$DOMAIN" \
--cert-file "$CERT_FILE" \
--key-file "$KEY_FILE" \
--reloadcmd "docker exec $(compose ps -q nginx | head -1) nginx -s reload" 2>/dev/null || true
elif command -v certbot >/dev/null 2>&1; then
certbot renew --cert-name "$DOMAIN" --non-interactive 2>&1 | tee -a "$ROOT_DIR/logs/cert-renew.log" || {
audit "renew-cert" "FAILED" "certbot renewal failed for $DOMAIN"
"$ROOT_DIR/scripts/alert-webhook.sh" "CERT_RENEW_FAILED" "Certificate renewal failed for $DOMAIN" 2>/dev/null || true
fail_json "XUQM_PRIVATE_5011" "certificate renewal failed for $DOMAIN" "renew-cert"
}
# Reload nginx
NGINX_CTR="$(compose ps -q nginx 2>/dev/null | head -1 || true)"
[ -n "$NGINX_CTR" ] && docker exec "$NGINX_CTR" nginx -s reload
else
fail_json "XUQM_PRIVATE_5012" "neither acme.sh nor certbot found" "renew-cert"
fi
audit "renew-cert" "DONE" "domain=$DOMAIN"
progress "renew-cert" "DONE" "domain=$DOMAIN"
printf 'Certificate renewed for %s\n' "$DOMAIN"
# Crontab setup hint (output to stdout)
printf '\n# Add to crontab for automatic renewal:\n'
printf '# 0 3 * * * %s/scripts/renew-cert.sh >> %s/logs/cert-renew.log 2>&1\n' "$ROOT_DIR" "$ROOT_DIR"

查看文件

@ -4,5 +4,82 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
fail_json "XUQM_PRIVATE_4003" "restore is not implemented yet; use backup manifest manually" "restore"
BACKUP_NAME="${1:-}"
[ -n "$BACKUP_NAME" ] || fail_json "XUQM_PRIVATE_4010" "usage: restore.sh <backup-name>" "restore"
BACKUP_DIR="$ROOT_DIR/data/backups"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_NAME"
[ -d "$BACKUP_PATH" ] || fail_json "XUQM_PRIVATE_4011" "backup not found: $BACKUP_PATH" "restore"
audit "restore" "STARTED" "backup=$BACKUP_NAME"
progress "restore" "STARTED" "backup=$BACKUP_NAME"
# Verify checksums
if [ -f "$BACKUP_PATH/sha256sums.txt" ]; then
(cd "$BACKUP_DIR" && sha256sum -c "$BACKUP_NAME/sha256sums.txt" --ignore-missing) || \
fail_json "XUQM_PRIVATE_4012" "checksum verification failed for $BACKUP_NAME" "restore"
audit "restore" "CHECKSUM_OK" "$BACKUP_NAME"
fi
# Stop running services
compose down 2>/dev/null || true
audit "restore" "SERVICES_STOPPED" ""
# Restore config (skip secrets.env if already exists)
if [ -f "$BACKUP_PATH/config.tar.gz" ]; then
tar -xzf "$BACKUP_PATH/config.tar.gz" -C "$ROOT_DIR" \
--exclude='config/secrets.env'
audit "restore" "CONFIG_DONE" "config restored (secrets.env preserved)"
fi
load_env
# Restore MySQL
if [ -f "$BACKUP_PATH/mysql.sql" ] && grep -qv '^#' "$BACKUP_PATH/mysql.sql" 2>/dev/null; then
if [ "${MYSQL_MODE:-external}" = "managed" ]; then
COMPOSE_PROFILES=infra-mysql compose up -d mysql
sleep 5
MYSQL_CTR="$(compose ps -q mysql | head -1)"
docker exec -i "$MYSQL_CTR" \
mysql -u root -p"${MYSQL_ROOT_PASSWORD:-}" "${MYSQL_DATABASE:-xuqm_private}" \
< "$BACKUP_PATH/mysql.sql"
audit "restore" "MYSQL_DONE" "restored to managed mysql"
elif command -v mysql >/dev/null 2>&1; then
mysql -h "${MYSQL_HOST:-127.0.0.1}" -P "${MYSQL_PORT:-3306}" \
-u "${MYSQL_USERNAME:-}" -p"${MYSQL_PASSWORD:-}" \
"${MYSQL_DATABASE:-xuqm_private}" < "$BACKUP_PATH/mysql.sql"
audit "restore" "MYSQL_DONE" "restored to external mysql"
else
audit "restore" "MYSQL_SKIPPED" "mysql client not available; restore manually"
printf 'WARNING: MySQL restore skipped. Restore manually with:\n'
printf ' mysql -h %s -P %s -u %s -p <password> %s < %s/mysql.sql\n' \
"${MYSQL_HOST:-}" "${MYSQL_PORT:-3306}" "${MYSQL_USERNAME:-}" \
"${MYSQL_DATABASE:-}" "$BACKUP_PATH"
fi
fi
# Restore Redis
if [ -f "$BACKUP_PATH/redis-dump.rdb" ]; then
if [ "${REDIS_MODE:-external}" = "managed" ]; then
COMPOSE_PROFILES=infra-redis compose up -d redis
sleep 3
REDIS_CTR="$(compose ps -q redis | head -1)"
compose pause redis 2>/dev/null || docker pause "$REDIS_CTR" 2>/dev/null || true
docker cp "$BACKUP_PATH/redis-dump.rdb" "$REDIS_CTR:/data/dump.rdb"
compose unpause redis 2>/dev/null || docker unpause "$REDIS_CTR" 2>/dev/null || true
docker restart "$REDIS_CTR"
audit "restore" "REDIS_DONE" "redis-dump.rdb restored to managed redis"
else
audit "restore" "REDIS_SKIPPED" "external Redis — restore dump.rdb manually"
printf 'WARNING: Redis restore skipped for external mode. Copy %s/redis-dump.rdb to Redis data dir.\n' "$BACKUP_PATH"
fi
fi
# Restart services
"$ROOT_DIR/scripts/render-config.sh"
COMPOSE_PROFILES="${COMPOSE_PROFILES:-base}" compose up -d
"$ROOT_DIR/scripts/healthcheck.sh"
audit "restore" "DONE" "backup=$BACKUP_NAME restored successfully"
progress "restore" "DONE" "backup=$BACKUP_NAME"
printf 'Restore complete from: %s\n' "$BACKUP_PATH"

查看文件

@ -3,6 +3,48 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
fail_json "XUQM_PRIVATE_4003" "rollback implementation pending release history" "rollback"
# Determine rollback target
TARGET_TAG="${1:-}"
if [ -z "$TARGET_TAG" ]; then
PREV_TAG_FILE="$ROOT_DIR/.deploy-state/previous-image-tag.txt"
[ -f "$PREV_TAG_FILE" ] || fail_json "XUQM_PRIVATE_4030" \
"no previous version recorded; pass target version as argument" "rollback"
TARGET_TAG="$(cat "$PREV_TAG_FILE" | tr -d '[:space:]')"
fi
CURRENT_TAG="${IMAGE_TAG:-$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')}"
audit "rollback" "STARTED" "from=$CURRENT_TAG to=$TARGET_TAG"
progress "rollback" "STARTED" "from=$CURRENT_TAG to=$TARGET_TAG"
printf 'Rolling back from %s to %s\n' "$CURRENT_TAG" "$TARGET_TAG"
# Confirm unless running in CI
if [ -t 0 ]; then
printf 'Confirm rollback to %s (y/N): ' "$TARGET_TAG"
read -r CONFIRM
[ "$CONFIRM" = "y" ] || { printf 'Rollback cancelled.\n'; exit 0; }
fi
# Save current tag as future rollback point
printf '%s\n' "$CURRENT_TAG" > "$ROOT_DIR/.deploy-state/previous-image-tag.txt"
# Switch to target tag
set_env_value "$ROOT_DIR/.env" "IMAGE_TAG" "$TARGET_TAG"
set_env_value "$ROOT_DIR/.env" "PRIVATE_VERSION" "$TARGET_TAG"
load_env
# Pull target images
PROFILES="${COMPOSE_PROFILES:-base}"
COMPOSE_PROFILES="$PROFILES" compose pull
"$ROOT_DIR/scripts/render-config.sh"
COMPOSE_PROFILES="$PROFILES" compose up -d --remove-orphans
if ! "$ROOT_DIR/scripts/healthcheck.sh"; then
fail_json "XUQM_PRIVATE_4031" "health check failed after rollback to $TARGET_TAG" "rollback"
fi
printf '%s\n' "$TARGET_TAG" > "$ROOT_DIR/VERSION"
audit "rollback" "DONE" "from=$CURRENT_TAG to=$TARGET_TAG"
progress "rollback" "DONE" "from=$CURRENT_TAG to=$TARGET_TAG"
printf 'Rollback complete: %s → %s\n' "$CURRENT_TAG" "$TARGET_TAG"

45
scripts/rotate-secrets.sh 可执行文件
查看文件

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Rotate secrets: generate new passwords, update secrets.env, restart services.
# This script does NOT automatically rotate external MySQL/Redis passwords.
# It only regenerates JWT and internal tokens. For MySQL/Redis, manual rotation is required.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
ROTATE_JWT="${ROTATE_JWT:-false}"
ROTATE_INTERNAL_TOKEN="${ROTATE_INTERNAL_TOKEN:-false}"
audit "rotate-secrets" "STARTED" "jwt=$ROTATE_JWT internal_token=$ROTATE_INTERNAL_TOKEN"
progress "rotate-secrets" "STARTED" ""
ensure_secret_file
if [ "$ROTATE_JWT" = "true" ]; then
NEW_JWT="$(random_secret)$(random_secret)"
set_env_value "$ROOT_DIR/config/secrets.env" "XUQM_JWT_SECRET" "$NEW_JWT"
audit "rotate-secrets" "JWT_ROTATED" "new key generated"
printf 'JWT secret rotated. All existing tokens will be invalidated on service restart.\n'
fi
if [ "$ROTATE_INTERNAL_TOKEN" = "true" ]; then
NEW_TOKEN="$(random_secret)"
set_env_value "$ROOT_DIR/config/secrets.env" "SDK_INTERNAL_TOKEN" "$NEW_TOKEN"
set_env_value "$ROOT_DIR/config/secrets.env" "LICENSE_INTERNAL_TOKEN" "$NEW_TOKEN"
audit "rotate-secrets" "INTERNAL_TOKEN_ROTATED" ""
printf 'Internal tokens rotated.\n'
fi
# Enforce permissions on secrets.env
chmod 600 "$ROOT_DIR/config/secrets.env"
printf '\nIMPORTANT: restart services for new secrets to take effect:\n'
printf ' docker compose --env-file .env -f docker-compose.yml -f docker-compose.infra.yml restart\n\n'
printf 'MySQL/Redis password rotation must be performed manually:\n'
printf ' 1. Set new password in the database\n'
printf ' 2. Update MYSQL_PASSWORD / REDIS_PASSWORD in config/secrets.env\n'
printf ' 3. Restart affected services\n'
audit "rotate-secrets" "DONE" ""
progress "rotate-secrets" "DONE" ""

查看文件

@ -3,9 +3,69 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
audit "upgrade" "STARTED" "${1:-}"
progress "upgrade" "STARTED" "${1:-}"
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"
fail_json "XUQM_PRIVATE_4003" "upgrade implementation pending compatibility matrix" "upgrade"
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"