XuqmGroup-PrivateDeploy/scripts/migrate-tenant.sh

333 行
14 KiB
Bash

#!/usr/bin/env bash
# migrate-tenant.sh — Migrate a tenant from the public platform to this private deployment.
#
# What it does:
# 1. Connects to the source (public) MySQL and locates the tenant by email or username.
# 2. Exports t_tenant, t_app, and t_feature_service rows for that tenant.
# 3. Replaces the bootstrap tenant in the private deployment's MySQL with the migrated data.
# 4. Restarts tenant-service to flush any in-memory state.
# 5. Verifies the migration via the SDK config API.
#
# Usage:
# ./scripts/migrate-tenant.sh [options]
#
# Required options:
# --src-host HOST Source MySQL host (production server)
# --src-user USER Source MySQL username
# --src-password PASS Source MySQL password
# --tenant EMAIL|USERNAME Tenant to migrate (matched against email or username)
#
# Optional options:
# --src-port PORT Source MySQL port (default: 3306)
# --src-db DB Source database name (default: xuqm_tenant)
# --dry-run Print generated SQL but do not apply it
# --reset-password PASS Override tenant password after migration (bcrypt-hashed on server)
# -h, --help Show this help
#
# The source MySQL client is resolved in order: docker exec (managed container),
# then a local mysql binary. At least one must be available.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$ROOT_DIR/scripts/lib.sh"
load_env
SRC_HOST=""
SRC_PORT="3306"
SRC_USER=""
SRC_PASSWORD=""
SRC_DB="xuqm_tenant"
TENANT_IDENT=""
DRY_RUN=false
RESET_PASSWORD=""
usage() {
awk '/^# Usage:/,/^[^#]/' "$0" | grep '^#' | sed 's/^# \{0,1\}//'
}
while [ "$#" -gt 0 ]; do
case "$1" in
--src-host) SRC_HOST="${2:-}"; shift 2 ;;
--src-host=*) SRC_HOST="${1#--src-host=}"; shift ;;
--src-port) SRC_PORT="${2:-}"; shift 2 ;;
--src-port=*) SRC_PORT="${1#--src-port=}"; shift ;;
--src-user) SRC_USER="${2:-}"; shift 2 ;;
--src-user=*) SRC_USER="${1#--src-user=}"; shift ;;
--src-password) SRC_PASSWORD="${2:-}"; shift 2 ;;
--src-password=*) SRC_PASSWORD="${1#--src-password=}"; shift ;;
--src-db) SRC_DB="${2:-}"; shift 2 ;;
--src-db=*) SRC_DB="${1#--src-db=}"; shift ;;
--tenant) TENANT_IDENT="${2:-}"; shift 2 ;;
--tenant=*) TENANT_IDENT="${1#--tenant=}"; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--reset-password) RESET_PASSWORD="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail_json "XUQM_PRIVATE_5001" "unknown option: $1" "migrate-tenant" ;;
esac
done
[ -n "$SRC_HOST" ] || fail_json "XUQM_PRIVATE_5002" "--src-host is required" "migrate-tenant"
[ -n "$SRC_USER" ] || fail_json "XUQM_PRIVATE_5003" "--src-user is required" "migrate-tenant"
[ -n "$SRC_PASSWORD" ] || fail_json "XUQM_PRIVATE_5004" "--src-password is required" "migrate-tenant"
[ -n "$TENANT_IDENT" ] || fail_json "XUQM_PRIVATE_5005" "--tenant is required" "migrate-tenant"
audit "migrate-tenant" "STARTED" "src=$SRC_HOST tenant=$TENANT_IDENT dry_run=$DRY_RUN"
progress "migrate-tenant" "STARTED" "src=$SRC_HOST tenant=$TENANT_IDENT"
# ---------------------------------------------------------------------------
# Resolve source and destination MySQL clients
# ---------------------------------------------------------------------------
# Source: always a remote host, so require a local mysql binary
require_cmd mysql
src_mysql() {
MYSQL_PWD="$SRC_PASSWORD" mysql \
-h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER" \
--default-character-set=utf8mb4 --connect-timeout=10 \
"$SRC_DB" "$@"
}
# Destination: prefer managed container, fall back to local binary
DST_MYSQL_CTR="$(compose ps -q mysql 2>/dev/null | head -1 || true)"
dst_mysql() {
if [ -n "$DST_MYSQL_CTR" ]; then
docker exec -i "$DST_MYSQL_CTR" \
mysql -u "${MYSQL_USERNAME:-xuqm}" -p"${MYSQL_PASSWORD:-}" \
--default-character-set=utf8mb4 \
"${MYSQL_DATABASE:-xuqm_private}" "$@"
elif command -v mysql >/dev/null 2>&1; then
MYSQL_PWD="${MYSQL_PASSWORD:-}" mysql \
-h "${MYSQL_HOST:-127.0.0.1}" -P "${MYSQL_PORT:-3306}" \
-u "${MYSQL_USERNAME:-xuqm}" \
--default-character-set=utf8mb4 \
"${MYSQL_DATABASE:-xuqm_private}" "$@"
else
fail_json "XUQM_PRIVATE_5006" "no destination MySQL client: container not running and mysql binary not found" "migrate-tenant"
fi
}
# ---------------------------------------------------------------------------
# Step 1 — Locate the tenant in the source database
# ---------------------------------------------------------------------------
printf '\n[1/5] Locating tenant "%s" in %s/%s ...\n' "$TENANT_IDENT" "$SRC_HOST" "$SRC_DB"
TENANT_ROW="$(src_mysql -N -e "
SELECT id, username, nickname, email, status
FROM t_tenant
WHERE (email = '${TENANT_IDENT}' OR username = '${TENANT_IDENT}')
AND type = 'MAIN'
LIMIT 1" 2>/dev/null || true)"
[ -n "$TENANT_ROW" ] || \
fail_json "XUQM_PRIVATE_5007" "tenant not found in source DB: $TENANT_IDENT" "migrate-tenant"
TENANT_ID="$(printf '%s' "$TENANT_ROW" | awk '{print $1}')"
TENANT_USERNAME="$(printf '%s' "$TENANT_ROW" | awk '{print $2}')"
TENANT_NICKNAME="$(printf '%s' "$TENANT_ROW" | awk '{print $3}')"
TENANT_EMAIL="$(printf '%s' "$TENANT_ROW" | awk '{print $4}')"
TENANT_STATUS="$(printf '%s' "$TENANT_ROW" | awk '{print $5}')"
printf ' Found: id=%s username=%s nickname=%s email=%s status=%s\n' \
"$TENANT_ID" "$TENANT_USERNAME" "$TENANT_NICKNAME" "$TENANT_EMAIL" "$TENANT_STATUS"
# Count associated data
APP_COUNT="$(src_mysql -N -e "SELECT COUNT(*) FROM t_app WHERE tenant_id='${TENANT_ID}'" 2>/dev/null || echo 0)"
FS_COUNT="$(src_mysql -N -e "
SELECT COUNT(*) FROM t_feature_service
WHERE app_key IN (SELECT app_key FROM t_app WHERE tenant_id='${TENANT_ID}')" 2>/dev/null || echo 0)"
printf ' Apps: %s FeatureServices: %s\n' "$APP_COUNT" "$FS_COUNT"
# ---------------------------------------------------------------------------
# Step 2 — Collect app keys for this tenant
# ---------------------------------------------------------------------------
APP_KEYS_RAW="$(src_mysql -N -e "SELECT app_key FROM t_app WHERE tenant_id='${TENANT_ID}'" 2>/dev/null || true)"
if [ -z "$APP_KEYS_RAW" ]; then
printf ' WARNING: tenant has no apps; only tenant record will be migrated.\n'
APP_KEYS_SQL="''"
else
# Build SQL IN-list: 'key1','key2',...
APP_KEYS_SQL="$(printf '%s' "$APP_KEYS_RAW" | awk '{printf "'"'"'%s'"'"',",$1}' | sed 's/,$//')"
fi
FIRST_APP_KEY="$(printf '%s' "$APP_KEYS_RAW" | head -1 || true)"
# ---------------------------------------------------------------------------
# Step 3 — Generate migration SQL
# ---------------------------------------------------------------------------
printf '\n[2/5] Generating migration SQL ...\n'
SQL_FILE="$(mktemp /tmp/xuqm-migrate-XXXXXX.sql)"
trap 'rm -f "$SQL_FILE"' EXIT
{
printf '-- XuqmGroup private deployment tenant migration\n'
printf '-- Source: %s/%s Tenant: %s (%s)\n' "$SRC_HOST" "$SRC_DB" "$TENANT_NICKNAME" "$TENANT_ID"
printf '-- Generated: %s\n\n' "$(now)"
printf 'SET FOREIGN_KEY_CHECKS=0;\n\n'
# Clear existing data (private deployment is single-tenant)
printf '-- Clear existing tenant data\n'
printf 'DELETE FROM t_feature_service;\n'
printf 'DELETE FROM t_app;\n'
printf 'DELETE FROM t_tenant;\n\n'
# t_tenant — use explicit column names to be schema-order agnostic
printf '-- Tenant record\n'
src_mysql -N -e "
SELECT CONCAT(
'INSERT INTO t_tenant (id,created_at,email,nickname,parent_id,password,phone,status,type,username) VALUES (',
QUOTE(id),',',QUOTE(created_at),',',QUOTE(email),',',QUOTE(nickname),',',
IFNULL(QUOTE(parent_id),'NULL'),',',QUOTE(password),',',
IFNULL(QUOTE(phone),'NULL'),',',QUOTE(status),',',QUOTE(type),',',QUOTE(username),');'
) FROM t_tenant WHERE id='${TENANT_ID}'" 2>/dev/null
printf '\n'
# t_app — explicit columns matching private schema (alphabetical: id,app_key,...,tenant_id)
if [ "$APP_COUNT" -gt 0 ]; then
printf '-- Apps\n'
src_mysql -N -e "
SELECT CONCAT(
'INSERT INTO t_app (id,app_key,app_secret,created_at,description,harmony_bundle_name,icon_url,ios_bundle_id,name,package_name,tenant_id) VALUES (',
QUOTE(id),',',QUOTE(app_key),',',QUOTE(app_secret),',',QUOTE(created_at),',',
IFNULL(QUOTE(description),'NULL'),',',IFNULL(QUOTE(harmony_bundle_name),'NULL'),',',
IFNULL(QUOTE(icon_url),'NULL'),',',IFNULL(QUOTE(ios_bundle_id),'NULL'),',',
QUOTE(name),',',QUOTE(package_name),',',QUOTE(tenant_id),');'
) FROM t_app WHERE tenant_id='${TENANT_ID}'" 2>/dev/null
printf '\n'
fi
# t_feature_service — explicit columns matching private schema (alphabetical: id,app_key,config,...)
if [ "$FS_COUNT" -gt 0 ]; then
printf '-- Feature services\n'
src_mysql -N -e "
SELECT CONCAT(
'INSERT INTO t_feature_service (id,app_key,config,created_at,enabled,platform,secret_key,service_type) VALUES (',
QUOTE(id),',',QUOTE(app_key),',',IFNULL(QUOTE(config),'NULL'),',',QUOTE(created_at),',',
CAST(enabled AS UNSIGNED),',',QUOTE(platform),',',QUOTE(secret_key),',',QUOTE(service_type),');'
) FROM t_feature_service WHERE app_key IN (${APP_KEYS_SQL})" 2>/dev/null
printf '\n'
fi
# Optional password reset via bcrypt (requires htpasswd or python3 available on the deployment host)
if [ -n "$RESET_PASSWORD" ]; then
printf '-- Password reset\n'
if command -v python3 >/dev/null 2>&1 && python3 -c "import bcrypt" 2>/dev/null; then
BCRYPT_HASH="$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'${RESET_PASSWORD}', bcrypt.gensalt(rounds=10)).decode())")"
elif command -v htpasswd >/dev/null 2>&1; then
BCRYPT_HASH="$(htpasswd -bnBC 10 '' "${RESET_PASSWORD}" | tr -d ':\n' | sed 's/\$2y/\$2a/')"
else
printf '-- WARNING: cannot generate bcrypt hash (install python3-bcrypt or apache2-utils)\n'
BCRYPT_HASH=""
fi
if [ -n "$BCRYPT_HASH" ]; then
printf "UPDATE t_tenant SET password='%s' WHERE id='%s';\n" "$BCRYPT_HASH" "$TENANT_ID"
fi
fi
printf 'SET FOREIGN_KEY_CHECKS=1;\n'
printf '-- Migration SQL complete (%s apps, %s feature services)\n' "$APP_COUNT" "$FS_COUNT"
} > "$SQL_FILE"
LINE_COUNT="$(wc -l < "$SQL_FILE")"
printf ' Generated %s lines of SQL\n' "$LINE_COUNT"
# ---------------------------------------------------------------------------
# Step 4 — Apply (or dry-run)
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "true" ]; then
printf '\n[DRY RUN] SQL that would be applied:\n'
printf '%s\n' "---"
cat "$SQL_FILE"
printf '%s\n' "---"
printf '\nDry run complete. Re-run without --dry-run to apply.\n'
audit "migrate-tenant" "DRY_RUN" "tenant=$TENANT_ID apps=$APP_COUNT"
exit 0
fi
printf '\n[3/5] Applying migration to private deployment ...\n'
dst_mysql < "$SQL_FILE"
audit "migrate-tenant" "SQL_APPLIED" "tenant=$TENANT_ID apps=$APP_COUNT fs=$FS_COUNT"
printf ' Done.\n'
# ---------------------------------------------------------------------------
# Step 5 — Restart tenant-service to flush any cached state
# ---------------------------------------------------------------------------
printf '\n[4/5] Restarting tenant-service ...\n'
TENANT_SVC_CTR="$(compose ps -q tenant-service 2>/dev/null | head -1 || true)"
if [ -n "$TENANT_SVC_CTR" ]; then
compose restart tenant-service
# Wait up to 30 s for the service to come back
WAITED=0
while [ "$WAITED" -lt 30 ]; do
sleep 3
WAITED=$((WAITED + 3))
HTTP_CODE="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 3 \
"http://localhost:${NGINX_PORT:-80}/actuator/health" 2>/dev/null || echo '000')"
[ "$HTTP_CODE" = "200" ] && break
done
[ "$HTTP_CODE" = "200" ] || printf ' WARNING: actuator/health not 200 after restart (HTTP %s); continuing.\n' "$HTTP_CODE"
printf ' tenant-service restarted.\n'
else
printf ' WARNING: tenant-service container not found; skipping restart.\n'
fi
# ---------------------------------------------------------------------------
# Step 6 — Verify
# ---------------------------------------------------------------------------
printf '\n[5/5] Verifying migration ...\n'
BASE_URL="http://localhost:${NGINX_PORT:-80}"
[ -n "${CONSOLE_DOMAIN:-}" ] && BASE_URL="${CONSOLE_DOMAIN}"
# Check tenant record
DST_TENANT="$(dst_mysql -N -e "
SELECT id, username, nickname, email FROM t_tenant WHERE id='${TENANT_ID}'" 2>/dev/null || true)"
if [ -n "$DST_TENANT" ]; then
printf ' DB check: PASS tenant record present (%s)\n' "$TENANT_NICKNAME"
else
fail_json "XUQM_PRIVATE_5008" "tenant record not found in destination after migration" "migrate-tenant"
fi
DST_APPS="$(dst_mysql -N -e "SELECT COUNT(*) FROM t_app WHERE tenant_id='${TENANT_ID}'" 2>/dev/null || echo 0)"
printf ' DB check: PASS %s app(s) migrated\n' "$DST_APPS"
# Check SDK config endpoint for the first migrated app
if [ -n "$FIRST_APP_KEY" ] && command -v curl >/dev/null 2>&1; then
SDK_CODE="$(curl -skL -o /dev/null -w '%{http_code}' --max-time 5 \
"${BASE_URL}/api/sdk/config?appKey=${FIRST_APP_KEY}&platform=ANDROID" 2>/dev/null || echo '000')"
if [ "$SDK_CODE" = "200" ]; then
printf ' API check: PASS /api/sdk/config returned 200 for %s\n' "$FIRST_APP_KEY"
else
printf ' API check: WARN /api/sdk/config returned %s for %s (service may still be warming up)\n' \
"$SDK_CODE" "$FIRST_APP_KEY"
fi
fi
# Confirm deployment status
if command -v curl >/dev/null 2>&1; then
DEPLOY_STATUS="$(curl -skL --max-time 5 "${BASE_URL}/api/private/deployment/status" 2>/dev/null || true)"
if printf '%s' "$DEPLOY_STATUS" | grep -q '"mode":"PRIVATE"'; then
REG_ENABLED="$(printf '%s' "$DEPLOY_STATUS" | grep -o '"tenantRegisterEnabled":[^,}]*' | cut -d: -f2)"
printf ' API check: PASS PRIVATE mode active, tenantRegisterEnabled=%s\n' "$REG_ENABLED"
else
printf ' API check: WARN deployment/status did not confirm PRIVATE mode\n'
fi
fi
printf '\nMigration complete.\n'
printf ' Tenant: %s (%s)\n' "$TENANT_NICKNAME" "$TENANT_EMAIL"
printf ' Apps: %s\n' "$DST_APPS"
[ -n "$RESET_PASSWORD" ] && printf ' Password: reset to provided value\n'
printf ' Login URL: %s\n' "${BASE_URL:-http://localhost}"
audit "migrate-tenant" "DONE" "tenant=$TENANT_ID apps=$DST_APPS"
progress "migrate-tenant" "DONE" "tenant=$TENANT_NICKNAME email=$TENANT_EMAIL apps=$DST_APPS"