diff --git a/config/tenant/bootstrap.env b/config/tenant/bootstrap.env index 32615d1..c17d174 100644 --- a/config/tenant/bootstrap.env +++ b/config/tenant/bootstrap.env @@ -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 diff --git a/config/vendors/push.env b/config/vendors/push.env index c7e7f05..0561228 100644 --- a/config/vendors/push.env +++ b/config/vendors/push.env @@ -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= diff --git a/config/vendors/store-submit.env b/config/vendors/store-submit.env index 688b1ce..164bc4e 100644 --- a/config/vendors/store-submit.env +++ b/config/vendors/store-submit.env @@ -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= diff --git a/config/xuqm.env b/config/xuqm.env index 52e2cef..7d7cf6a 100644 --- a/config/xuqm.env +++ b/config/xuqm.env @@ -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 + diff --git a/scripts/alert-webhook.sh b/scripts/alert-webhook.sh new file mode 100755 index 0000000..5e7b8c0 --- /dev/null +++ b/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 diff --git a/scripts/backup.sh b/scripts/backup.sh index c813355..1090d3f 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -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 --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" < "$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" diff --git a/scripts/bench.sh b/scripts/bench.sh new file mode 100755 index 0000000..1155d42 --- /dev/null +++ b/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" <&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' diff --git a/scripts/disable-service.sh b/scripts/disable-service.sh index 71e2de9..058758b 100755 --- a/scripts/disable-service.sh +++ b/scripts/disable-service.sh @@ -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" diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 0d7b58a..05b4b07 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -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 | \ + 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" diff --git a/scripts/enable-service.sh b/scripts/enable-service.sh index ac814c0..c7cb07b 100755 --- a/scripts/enable-service.sh +++ b/scripts/enable-service.sh @@ -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" diff --git a/scripts/export-offline-bundle.sh b/scripts/export-offline-bundle.sh index 2703438..ac254a4 100755 --- a/scripts/export-offline-bundle.sh +++ b/scripts/export-offline-bundle.sh @@ -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")" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 7ed8176..c2bb3fd 100755 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -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" < "$ROOT_DIR/.deploy-state/current.json" </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" diff --git a/scripts/restore.sh b/scripts/restore.sh index 072c0c2..f8e5026 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -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 " "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 %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" diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 50e1d8c..e0598d3 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -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" diff --git a/scripts/rotate-secrets.sh b/scripts/rotate-secrets.sh new file mode 100755 index 0000000..6f2bef1 --- /dev/null +++ b/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" "" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 2d1948b..50f07ae 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -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 " "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"