#!/usr/bin/env bash set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=scripts/cluster/common.sh source "$script_dir/common.sh" action=${1:-preflight} release_manifest=${2:-} [[ $action == preflight || $action == cutover ]] || { echo 'usage: migrate-production.sh {preflight|cutover} dist/releases/.json' >&2 exit 64 } [[ -f $release_manifest && ! -L $release_manifest ]] || { echo 'release manifest must be a regular file' >&2 exit 1 } load_cluster_env node "$cluster_root/scripts/release-manifest.mjs" validate "$release_manifest" >/dev/null source_sha=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" sourceSha) api_image=$(node "$cluster_root/scripts/release-manifest.mjs" get "$release_manifest" images.api) if [[ $action == cutover && ${AI_GATEWAY_CUTOVER_WINDOW_OVERRIDE:-} != I_ACCEPT_PRODUCTION_RISK ]]; then shanghai_hour=$(TZ=Asia/Shanghai date '+%H') if (( 10#$shanghai_hour < 2 || 10#$shanghai_hour >= 4 )); then echo 'cutover is restricted to 02:00-04:00 Asia/Shanghai' >&2 exit 1 fi fi echo '[cutover] checking Docker, K3s, CNPG, disk and backup prerequisites' cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE' set -euo pipefail docker inspect easyai-ai-gateway-api-1 easyai-ai-gateway-postgres-1 >/dev/null docker exec easyai-ai-gateway-postgres-1 pg_isready >/dev/null k3s kubectl get --raw=/readyz >/dev/null [[ $(k3s kubectl get nodes --no-headers | awk '$2 == "Ready" { count++ } END { print count + 0 }') -eq 3 ]] [[ $(k3s kubectl get cluster easyai-postgres -n easyai -o jsonpath='{.status.readyInstances}') -eq 2 ]] primary=$(k3s kubectl get cluster easyai-postgres -n easyai -o jsonpath='{.status.currentPrimary}') [[ $primary =~ ^easyai-postgres-[0-9]+$ ]] for pod in $(k3s kubectl get pods -n easyai -l cnpg.io/cluster=easyai-postgres \ -o jsonpath='{.items[*].metadata.name}'); do k3s kubectl exec -n easyai "$pod" -c postgres -- \ test -r /projected/barman-aws/config done archived_before=$(k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -U postgres -d postgres -At -c \ 'SELECT archived_count FROM pg_stat_archiver;') archived_after=$(k3s kubectl exec -i -n easyai "$primary" -c postgres -- \ bash -s -- "$archived_before" <<'POD' set -euo pipefail archived_before=$1 psql -X -v ON_ERROR_STOP=1 -U postgres -d postgres -At \ -c "SELECT pg_logical_emit_message(true, 'easyai-ha-preflight', clock_timestamp()::text);" \ -c 'SELECT pg_switch_wal();' >/dev/null for _ in $(seq 1 45); do archived_after=$(psql -X -U postgres -d postgres -At -c \ 'SELECT archived_count FROM pg_stat_archiver;') if (( archived_after > archived_before )); then printf '%s\n' "$archived_after" exit 0 fi sleep 2 done exit 1 POD ) (( archived_after > archived_before )) [[ $(k3s kubectl get cluster easyai-postgres -n easyai \ -o jsonpath='{.status.conditions[?(@.type=="ContinuousArchiving")].status}') == True ]] latest_completed_backup=$(k3s kubectl get backups -n easyai -o json | jq -r '[.items[] | select( .spec.cluster.name == "easyai-postgres" and .status.phase == "completed" ) | .status.stoppedAt] | sort | last // empty') [[ -n $latest_completed_backup ]] latest_completed_epoch=$(date -d "$latest_completed_backup" '+%s') (( $(date '+%s') - latest_completed_epoch < 172800 )) [[ $(df --output=avail -B1 /var/lib/docker | tail -1) -gt 2147483648 ]] [[ -f /etc/nginx/sites-available/ai.51easyai.com-maintenance ]] [[ -f /etc/nginx/sites-available/ai.51easyai.com-cluster ]] nginx -t REMOTE if [[ $action == preflight ]]; then echo "production_migration_preflight=PASS release=$source_sha" exit 0 fi working_directory=$(mktemp -d "$cluster_root/.local-secrets/cluster/cutover.XXXXXX") trap '[[ $working_directory == "$cluster_root"/.local-secrets/cluster/cutover.* ]] && rm -rf -- "$working_directory"' EXIT HUP INT TERM cluster_scp "$script_dir/oss-object.mjs" "$CLUSTER_NINGBO_HOST:/root/easyai-oss-object.mjs" remote_cutover_script="$working_directory/remote-cutover.sh" umask 077 cp /dev/null "$remote_cutover_script" chmod 0700 "$remote_cutover_script" cat >"$remote_cutover_script" <<'REMOTE' #!/usr/bin/env bash set -euo pipefail source_sha=$1 api_image=$2 active_site=${AI_GATEWAY_NGINX_ACTIVE_SITE:-/etc/nginx/conf.d/easyai-ai-gateway-ai.51easyai.com.conf} timestamp=$(date -u '+%Y%m%dT%H%M%SZ') backup_directory=/var/backups/easyai-ai-gateway/pre-cutover-"$timestamp" old_site_backup=$backup_directory/nginx-site.conf cutover_started=$(date +%s) traffic_committed=false rollback_before_commit() { status=$? trap - ERR if [[ $traffic_committed == false ]]; then echo '[cutover] rolling back to the frozen Docker database' >&2 k3s kubectl scale deployment -n easyai \ easyai-api-ningbo easyai-api-hongkong easyai-web-ningbo easyai-web-hongkong \ --replicas=0 >/dev/null 2>&1 || true docker start easyai-ai-gateway-api-1 easyai-ai-gateway-web-1 >/dev/null 2>&1 || true if [[ -f $old_site_backup ]]; then install -m 0644 "$old_site_backup" "$active_site" nginx -t && systemctl reload nginx fi else echo '[cutover] traffic is already committed; automatic database rollback is unsafe' >&2 fi exit "$status" } trap rollback_before_commit ERR install -d -m 0700 "$backup_directory" [[ -e $active_site || -L $active_site ]] || { echo "active NGINX site is unavailable: $active_site" >&2 exit 1 } cp -L "$active_site" "$old_site_backup" chmod 0600 "$old_site_backup" install -m 0644 /etc/nginx/sites-available/ai.51easyai.com-maintenance "$active_site" nginx -t systemctl reload nginx maintenance_code= for _ in $(seq 1 15); do maintenance_code=$(curl -ksS --resolve ai.51easyai.com:443:127.0.0.1 \ -o /dev/null -w '%{http_code}' https://ai.51easyai.com/api/v1/healthz || true) [[ $maintenance_code == 503 ]] && break sleep 1 done [[ $maintenance_code == 503 ]] || { echo "maintenance NGINX did not become active: HTTP $maintenance_code" >&2 exit 1 } docker stop --time 60 easyai-ai-gateway-api-1 >/dev/null postgres_user=$(docker inspect easyai-ai-gateway-postgres-1 \ --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= '$1 == "POSTGRES_USER" { print substr($0, index($0, "=") + 1) }') postgres_database=$(docker inspect easyai-ai-gateway-postgres-1 \ --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= '$1 == "POSTGRES_DB" { print substr($0, index($0, "=") + 1) }') [[ $postgres_user =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] [[ $postgres_database =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] snapshot_sql=$backup_directory/acceptance-snapshot.sql cat >"$snapshot_sql" <<'SQL' \pset tuples_only on \pset format unaligned SELECT 'server_version_num=' || current_setting('server_version_num'); SELECT 'extensions=' || COALESCE(string_agg(extname || ':' || extversion, ',' ORDER BY extname), '') FROM pg_extension WHERE extname <> 'plpgsql'; SELECT 'tables=' || count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname = 'public'; SELECT 'sequences=' || COALESCE(string_agg(schemaname || '.' || sequencename || ':' || COALESCE(last_value::text, 'null'), ',' ORDER BY schemaname, sequencename), '') FROM pg_sequences WHERE schemaname = 'public'; SELECT 'catalog=' || md5(COALESCE(string_agg(definition, E'\n' ORDER BY definition), '')) FROM ( SELECT 'column:' || table_name || ':' || column_name || ':' || data_type || ':' || is_nullable || ':' || COALESCE(column_default, '') AS definition FROM information_schema.columns WHERE table_schema = 'public' UNION ALL SELECT 'constraint:' || conrelid::regclass::text || ':' || conname || ':' || pg_get_constraintdef(oid) FROM pg_constraint WHERE connamespace = 'public'::regnamespace UNION ALL SELECT 'index:' || indexrelid::regclass::text || ':' || pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid IN ( SELECT oid FROM pg_class WHERE relnamespace = 'public'::regnamespace ) ) definitions; SELECT 'gateway_tasks=' || count(*) FROM gateway_tasks; SELECT 'gateway_task_attempts=' || count(*) FROM gateway_task_attempts; SELECT 'gateway_wallet_transactions=' || count(*) FROM gateway_wallet_transactions; SELECT 'gateway_upload_assets=' || count(*) FROM gateway_upload_assets; SELECT 'gateway_users=' || count(*) FROM gateway_users; SELECT 'gateway_api_keys=' || count(*) FROM gateway_api_keys; SELECT 'model_catalog_providers=' || count(*) FROM model_catalog_providers; SELECT 'task_sample=' || md5(COALESCE(string_agg(id::text, ',' ORDER BY id), '')) FROM (SELECT id FROM gateway_tasks ORDER BY id LIMIT 100) sample; SQL docker exec -i easyai-ai-gateway-postgres-1 \ psql -X -v ON_ERROR_STOP=1 -U "$postgres_user" -d "$postgres_database" \ <"$snapshot_sql" >"$backup_directory/source.snapshot" docker exec easyai-ai-gateway-postgres-1 \ pg_dumpall -U "$postgres_user" --globals-only \ >"$backup_directory/globals.sql" docker exec easyai-ai-gateway-postgres-1 \ pg_dump -U "$postgres_user" -d "$postgres_database" \ --format=custom --compress=6 \ >"$backup_directory/database.dump" [[ -s $backup_directory/database.dump && -s $backup_directory/globals.sql ]] docker exec -i easyai-ai-gateway-postgres-1 pg_restore -l \ <"$backup_directory/database.dump" >/dev/null sha256sum "$backup_directory/database.dump" "$backup_directory/globals.sql" \ "$backup_directory/source.snapshot" >"$backup_directory/SHA256SUMS" sha256sum -c "$backup_directory/SHA256SUMS" export ALI_KEY export ALI_SECRET ALI_KEY=$(k3s kubectl get secret easyai-oss-backup -n easyai \ -o jsonpath='{.data.ACCESS_KEY_ID}' | base64 -d) ALI_SECRET=$(k3s kubectl get secret easyai-oss-backup -n easyai \ -o jsonpath='{.data.ACCESS_SECRET_KEY}' | base64 -d) export ALI_REGION=oss-cn-shanghai export ALI_BUCKET=wangbo0808 object_prefix=easyai-ai-gateway/production/pre-cutover/"$timestamp" for file in database.dump globals.sql source.snapshot SHA256SUMS; do node /root/easyai-oss-object.mjs put "$object_prefix/$file" "$backup_directory/$file" done node /root/easyai-oss-object.mjs head "$object_prefix/database.dump" primary=$(k3s kubectl get cluster easyai-postgres -n easyai \ -o jsonpath='{.status.currentPrimary}') [[ $primary =~ ^easyai-postgres-[0-9]+$ ]] k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d postgres \ -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'easyai_ai_gateway' AND pid <> pg_backend_pid();" \ -c 'DROP DATABASE IF EXISTS easyai_ai_gateway;' \ -c 'CREATE DATABASE easyai_ai_gateway OWNER easyai;' k3s kubectl exec -i -n easyai "$primary" -c postgres -- \ pg_restore --exit-on-error --no-owner --role=easyai \ -U postgres -d easyai_ai_gateway <"$backup_directory/database.dump" k3s kubectl exec -i -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway \ <"$snapshot_sql" >"$backup_directory/restored.snapshot" diff -u "$backup_directory/source.snapshot" "$backup_directory/restored.snapshot" cat >"$backup_directory/migrator-job.yaml" </dev/null k3s kubectl apply -f "$backup_directory/migrator-job.yaml" >/dev/null k3s kubectl wait --for=condition=complete \ "job/easyai-migrator-${source_sha:0:12}" -n easyai --timeout=300s k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c \ "SELECT count(*) FROM schema_migrations WHERE version='0089_file_storage_request_asset_scene';" | grep -qx 1 k3s kubectl exec -n easyai "$primary" -c postgres -- \ psql -X -v ON_ERROR_STOP=1 -U postgres -d easyai_ai_gateway -At -c \ "SELECT count(*) FROM file_storage_channels WHERE deleted_at IS NULL AND provider='server_main_openapi' AND config->'scenes' ? 'request_asset';" | grep -Eq '^[1-9][0-9]*$' elapsed=$(( $(date +%s) - cutover_started )) if (( elapsed > 600 )); then echo "cutover exceeded ten-minute rollback boundary: ${elapsed}s" >&2 exit 1 fi printf '%s\n' "$backup_directory" >/root/.easyai-pre-cutover-backup printf 'database_restore=PASS seconds=%s backup=%s\n' "$elapsed" "$backup_directory" REMOTE cluster_scp "$remote_cutover_script" "$CLUSTER_NINGBO_HOST:/root/easyai-remote-cutover.sh" cluster_ssh "$CLUSTER_NINGBO_HOST" \ "chmod 0700 /root/easyai-remote-cutover.sh && /root/easyai-remote-cutover.sh '$source_sha' '$api_image'" traffic_committed=false rollback_local_before_commit() { status=$? trap - ERR if [[ $traffic_committed == false ]]; then echo '[cutover] local activation failed; restoring Docker before public traffic commit' >&2 cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE' || true set -euo pipefail backup_directory=$(cat /root/.easyai-pre-cutover-backup) [[ $backup_directory == /var/backups/easyai-ai-gateway/pre-cutover-* ]] k3s kubectl scale deployment -n easyai \ easyai-api-ningbo easyai-api-hongkong easyai-web-ningbo easyai-web-hongkong \ --replicas=0 >/dev/null 2>&1 || true docker start easyai-ai-gateway-api-1 easyai-ai-gateway-web-1 >/dev/null install -m 0644 "$backup_directory/nginx-site.conf" \ /etc/nginx/conf.d/easyai-ai-gateway-ai.51easyai.com.conf nginx -t systemctl reload nginx REMOTE fi exit "$status" } trap rollback_local_before_commit ERR echo '[cutover] starting both application sites with River workers frozen' AI_GATEWAY_ACTIVATE_WORKERS=false \ "$script_dir/bootstrap-platform.sh" activate "$release_manifest" for site_endpoint in 10.77.0.1:31088:31178 10.77.0.2:31089:31179; do IFS=: read -r site_ip api_port web_port <<<"$site_endpoint" cluster_ssh "$CLUSTER_NINGBO_HOST" \ "curl -fsS --max-time 10 http://$site_ip:$api_port/api/v1/healthz >/dev/null" cluster_ssh "$CLUSTER_NINGBO_HOST" \ "curl -fsS --max-time 10 http://$site_ip:$api_port/api/v1/readyz | grep -q '\"ok\":true'" cluster_ssh "$CLUSTER_NINGBO_HOST" \ "curl -fsS --max-time 10 http://$site_ip:$web_port/ | grep -q 'EasyAI AI Gateway'" done "$script_dir/install-nginx-edges.sh" activate for edge_host in "${CLUSTER_NINGBO_HOST#*@}" "${CLUSTER_HONGKONG_HOST#*@}"; do curl -fsS --resolve "ai.51easyai.com:443:$edge_host" \ https://ai.51easyai.com/api/v1/healthz >/dev/null curl -fsS --resolve "ai.51easyai.com:443:$edge_host" \ https://ai.51easyai.com/api/v1/readyz | grep -q '"ok":true' done traffic_committed=true echo '[cutover] traffic committed; enabling Hong Kong worker, then Ningbo worker' cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE' set -euo pipefail kubectl='k3s kubectl' $kubectl set env deployment/easyai-api-hongkong -n easyai \ AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=true $kubectl rollout status deployment/easyai-api-hongkong -n easyai --timeout=300s $kubectl set env deployment/easyai-api-ningbo -n easyai \ AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED=true $kubectl rollout status deployment/easyai-api-ningbo -n easyai --timeout=300s backup_resource=$(cat <<'EOF' | $kubectl create -f - -o name apiVersion: postgresql.cnpg.io/v1 kind: Backup metadata: generateName: easyai-post-cutover- namespace: easyai spec: cluster: name: easyai-postgres method: plugin pluginConfiguration: name: barman-cloud.cloudnative-pg.io EOF ) backup_name=${backup_resource#*/} backup_phase= for _ in $(seq 1 240); do backup_phase=$($kubectl get backup "$backup_name" -n easyai \ -o jsonpath='{.status.phase}') [[ $backup_phase == completed ]] && break if [[ $backup_phase == failed ]]; then echo "post-cutover CNPG backup failed: $backup_name" >&2 exit 1 fi sleep 5 done [[ $backup_phase == completed ]] || { echo "post-cutover CNPG backup did not complete in time: $backup_name" >&2 exit 1 } docker update --restart=no easyai-ai-gateway-api-1 \ easyai-ai-gateway-web-1 easyai-ai-gateway-postgres-1 >/dev/null docker stop --time 30 easyai-ai-gateway-web-1 easyai-ai-gateway-postgres-1 >/dev/null rm -f -- /root/easyai-remote-cutover.sh /root/easyai-oss-object.mjs echo "post_cutover_backup=PASS name=$backup_name" REMOTE "$script_dir/install-release-helper.sh" remote_manifest=/tmp/easyai-cutover-release-"$source_sha".json cluster_scp "$release_manifest" "$CLUSTER_NINGBO_HOST:$remote_manifest" cluster_ssh "$CLUSTER_NINGBO_HOST" \ "/usr/local/sbin/easyai-ai-gateway-cluster-release adopt '$remote_manifest' && rm -f '$remote_manifest'" set_local_environment() { local key=$1 local value=$2 local temporary temporary=$(mktemp "$cluster_env_file.next.XXXXXX") chmod 0600 "$temporary" awk -v key="$key" -v value="$value" -F= ' $1 == key { if (!written) print key "=" value; written = 1; next } { print } END { if (!written) print key "=" value } ' "$cluster_env_file" >"$temporary" mv "$temporary" "$cluster_env_file" } set_local_environment AI_GATEWAY_DEPLOY_MODE kubernetes set_local_environment AI_GATEWAY_REMOTE_RELEASE_HELPER /usr/local/sbin/easyai-ai-gateway-cluster-release set_local_environment AI_GATEWAY_PRODUCTION_SSH_KEY "$cluster_ssh_key" trap - ERR echo "production_cutover=PASS release=$source_sha old_postgres_retained=true"