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>
这个提交包含在:
父节点
4ada03183a
当前提交
9eabe0d699
@ -1,4 +1,5 @@
|
|||||||
TENANT_BOOTSTRAP_EMAIL=admin@customer.com
|
TENANT_BOOTSTRAP_EMAIL=admin@customer.com
|
||||||
|
TENANT_BOOTSTRAP_USERNAME=admin
|
||||||
TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login
|
TENANT_BOOTSTRAP_PASSWORD=change-me-on-first-login
|
||||||
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
|
TENANT_BOOTSTRAP_APP_KEY=ak_private_default
|
||||||
|
|
||||||
|
|||||||
44
config/vendors/push.env
vendored
44
config/vendors/push.env
vendored
@ -1,7 +1,41 @@
|
|||||||
|
# ========== Huawei Push (HMS) ==========
|
||||||
HUAWEI_PUSH_ENABLED=false
|
HUAWEI_PUSH_ENABLED=false
|
||||||
MI_PUSH_ENABLED=false
|
# HUAWEI_PUSH_APP_ID=
|
||||||
OPPO_PUSH_ENABLED=false
|
# HUAWEI_PUSH_APP_SECRET=
|
||||||
VIVO_PUSH_ENABLED=false
|
# HUAWEI_PUSH_CLIENT_ID=
|
||||||
HONOR_PUSH_ENABLED=false
|
# HUAWEI_PUSH_CLIENT_SECRET=
|
||||||
APNS_ENABLED=false
|
|
||||||
|
|
||||||
|
# ========== 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=
|
||||||
|
|||||||
30
config/vendors/store-submit.env
vendored
30
config/vendors/store-submit.env
vendored
@ -1,6 +1,28 @@
|
|||||||
|
# ========== Huawei AppGallery Connect ==========
|
||||||
HUAWEI_STORE_ENABLED=false
|
HUAWEI_STORE_ENABLED=false
|
||||||
MI_STORE_ENABLED=false
|
# HUAWEI_STORE_CLIENT_ID=
|
||||||
OPPO_STORE_ENABLED=false
|
# HUAWEI_STORE_CLIENT_SECRET=
|
||||||
VIVO_STORE_ENABLED=false
|
# HUAWEI_STORE_APP_ID=
|
||||||
HONOR_STORE_ENABLED=false
|
|
||||||
|
|
||||||
|
# ========== 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
|
LICENSE_DOMAIN=https://license.customer.com
|
||||||
PUSH_DOMAIN=https://push.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
可执行文件
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$ROOT_DIR/scripts/lib.sh"
|
||||||
|
load_env
|
||||||
|
|
||||||
audit "backup" "STARTED" "creating config backup"
|
BACKUP_DIR="$ROOT_DIR/data/backups"
|
||||||
progress "backup" "STARTED" "creating config backup"
|
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"
|
audit "backup" "STARTED" "backup=$BACKUP_NAME"
|
||||||
tar --exclude='config/secrets.env' -czf "$ROOT_DIR/data/backups/config-$(date +%Y%m%d%H%M%S).tar.gz" \
|
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
|
-C "$ROOT_DIR" VERSION .env config .deploy-state
|
||||||
|
audit "backup" "CONFIG_DONE" "config.tar.gz"
|
||||||
|
|
||||||
audit "backup" "DONE" "backup created"
|
# MySQL backup
|
||||||
progress "backup" "DONE" "backup created"
|
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
可执行文件
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$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"
|
audit "configure" "STARTED" "rendering initial config"
|
||||||
progress "configure" "STARTED" "rendering initial config"
|
progress "configure" "STARTED" "rendering initial config"
|
||||||
|
|
||||||
|
# Initialize from template if missing
|
||||||
if [ ! -f "$ROOT_DIR/.env" ]; then
|
if [ ! -f "$ROOT_DIR/.env" ]; then
|
||||||
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_secret_file
|
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"
|
"$ROOT_DIR/scripts/render-config.sh"
|
||||||
|
|
||||||
audit "configure" "DONE" "config ready"
|
audit "configure" "DONE" "config ready"
|
||||||
progress "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"
|
. "$ROOT_DIR/scripts/lib.sh"
|
||||||
|
|
||||||
SERVICE="${1:-}"
|
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
|
if [ ! -f "$ROOT_DIR/.env" ]; then
|
||||||
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
||||||
@ -16,15 +16,22 @@ audit "disable-service" "STARTED" "$SERVICE"
|
|||||||
progress "disable-service" "STARTED" "$SERVICE"
|
progress "disable-service" "STARTED" "$SERVICE"
|
||||||
|
|
||||||
case "$SERVICE" in
|
case "$SERVICE" in
|
||||||
im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "false" ;;
|
im|push|update|license) ;;
|
||||||
push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "false" ;;
|
*) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE (valid: im push update license)" "disable-service" ;;
|
||||||
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" ;;
|
|
||||||
esac
|
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"
|
"$ROOT_DIR/scripts/render-config.sh"
|
||||||
|
|
||||||
audit "disable-service" "DONE" "$SERVICE"
|
audit "disable-service" "DONE" "$SERVICE profiles=$NEW_PROFILES"
|
||||||
progress "disable-service" "DONE" "$SERVICE"
|
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$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"
|
audit "doctor" "STARTED" "collecting diagnostics"
|
||||||
progress "doctor" "STARTED" "collecting diagnostics"
|
progress "doctor" "STARTED" "collecting diagnostics"
|
||||||
|
|
||||||
mkdir -p "$ROOT_DIR/dist"
|
mkdir -p "$DIAG_DIR"
|
||||||
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
|
|
||||||
|
|
||||||
audit "doctor" "DONE" "diagnostics collected"
|
# System info
|
||||||
progress "doctor" "DONE" "diagnostics collected"
|
{
|
||||||
|
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"
|
. "$ROOT_DIR/scripts/lib.sh"
|
||||||
|
|
||||||
SERVICE="${1:-}"
|
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
|
if [ ! -f "$ROOT_DIR/.env" ]; then
|
||||||
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
|
||||||
@ -16,15 +16,39 @@ audit "enable-service" "STARTED" "$SERVICE"
|
|||||||
progress "enable-service" "STARTED" "$SERVICE"
|
progress "enable-service" "STARTED" "$SERVICE"
|
||||||
|
|
||||||
case "$SERVICE" in
|
case "$SERVICE" in
|
||||||
im) set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "true" ;;
|
im)
|
||||||
push) set_env_value "$ROOT_DIR/.env" "ENABLE_PUSH" "true" ;;
|
set_env_value "$ROOT_DIR/.env" "ENABLE_IM" "true"
|
||||||
update) set_env_value "$ROOT_DIR/.env" "ENABLE_UPDATE" "true" ;;
|
[ -n "${IM_DOMAIN:-}" ] || fail_json "XUQM_PRIVATE_1003" "IM_DOMAIN must be set before enabling im" "enable-service"
|
||||||
license) set_env_value "$ROOT_DIR/.env" "ENABLE_LICENSE" "true" ;;
|
;;
|
||||||
*) fail_json "XUQM_PRIVATE_1002" "unknown service: $SERVICE" "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
|
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"
|
"$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"
|
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$ROOT_DIR/scripts/lib.sh"
|
||||||
|
load_env
|
||||||
|
|
||||||
OUT_DIR="${1:-$ROOT_DIR/dist}"
|
OUT_DIR="${1:-$ROOT_DIR/dist}"
|
||||||
mkdir -p "$OUT_DIR"
|
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"
|
audit "export-offline-bundle" "STARTED" "$OUT_DIR"
|
||||||
progress "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"
|
# Copy deploy repo (exclude data, dist, git, secrets)
|
||||||
progress "export-offline-bundle" "DONE" "$OUT_DIR"
|
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"
|
audit "healthcheck" "STARTED" "running checks"
|
||||||
progress "healthcheck" "STARTED" "running checks"
|
progress "healthcheck" "STARTED" "running checks"
|
||||||
|
|
||||||
require_cmd docker
|
OVERALL="UP"
|
||||||
|
RESULTS=()
|
||||||
|
WARNINGS=()
|
||||||
|
|
||||||
STATUS="UP"
|
check_pass() { RESULTS+=("{\"check\":\"$1\",\"status\":\"OK\",\"detail\":\"$2\"}"); }
|
||||||
WARNINGS="[]"
|
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
|
# Docker daemon
|
||||||
STATUS="DOWN"
|
if docker info >/dev/null 2>&1; then
|
||||||
|
check_pass "docker" "daemon running"
|
||||||
|
else
|
||||||
|
check_fail "docker" "daemon not available"
|
||||||
fi
|
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
|
cat > "$ROOT_DIR/.deploy-state/last-healthcheck.json" <<EOF
|
||||||
{
|
{
|
||||||
"status": "$STATUS",
|
"status": "$OVERALL",
|
||||||
"version": "${PRIVATE_VERSION:-2026.05.18-private.1}",
|
"timestamp": "$(now)",
|
||||||
"mysql": {"mode": "${MYSQL_MODE:-external}", "status": "UNKNOWN"},
|
"version": "${PRIVATE_VERSION:-unknown}",
|
||||||
"redis": {"mode": "${REDIS_MODE:-external}", "status": "UNKNOWN"},
|
"mysqlMode": "${MYSQL_MODE:-external}",
|
||||||
"warnings": $WARNINGS
|
"redisMode": "${REDIS_MODE:-external}",
|
||||||
|
"profiles": "${COMPOSE_PROFILES:-base}",
|
||||||
|
"checks": $RESULTS_JSON,
|
||||||
|
"warnings": $WARNINGS_JSON
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
audit "healthcheck" "$STATUS" "healthcheck finished"
|
audit "healthcheck" "$OVERALL" "checks=${#RESULTS[@]}"
|
||||||
progress "healthcheck" "$STATUS" "healthcheck finished"
|
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
|
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"
|
audit "render-config" "DONE" "runtime files rendered"
|
||||||
progress "render-config" "DONE" "runtime files rendered"
|
progress "render-config" "DONE" "runtime files rendered"
|
||||||
|
|||||||
71
scripts/renew-cert.sh
可执行文件
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$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
可执行文件
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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
. "$ROOT_DIR/scripts/lib.sh"
|
. "$ROOT_DIR/scripts/lib.sh"
|
||||||
|
load_env
|
||||||
|
|
||||||
audit "upgrade" "STARTED" "${1:-}"
|
TARGET_VERSION="${1:-}"
|
||||||
progress "upgrade" "STARTED" "${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"
|
"$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"
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户