#!/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"