#!/bin/bash # Перенос инстансов на другой куб (DST, по умолчанию loop-3) целиком. # loopctl k8s move loop0f66acff77e2 loop5ad226321771 ... # На каждый инстанс: # 1. k8s/rebind.sh: секреты на DST, привязка в визоре (куб, провайдер БД, аннотация ingress) # 2. env в визоре через API: DATASOURCE/DATASOURCEREPLICAS берутся из CR на исходном кубе # 3. sync через API, ждём под на DST, проверяем MM_LICENSE, ping/БД/файлы, лицензию, env против исходного CR # 4. прокси на исходном кубе, выключить там ingress, проверить через прокси, replicas 0 # 5. DNS CNAME -> DST_DNS, запись в state/proxy-cleanup.txt # ALLOW_ENV_DIFF=1 — не останавливаться, если env в новом CR отличаются от исходного (только показать). # ALLOW_REPLICAS=N — переносить инстанс с replicas=N на источнике (по умолчанию только 1). # ALLOW_DB_MISMATCH=1 — база в CR и в секрете на источнике разные, брать секрет. # PARALLEL=1 — до 10 инстансов одновременно, каждый в свой лог state/batch-logs/<время>/.log. # Без PARALLEL: любая ошибка = стоп всей пачки. До шага 4 исходный инстанс не трогается. set -euo pipefail HERE=$(cd "$(dirname "$0")" && pwd) . "$HERE/../lib/common.sh" API=$VIZOR_API LB=$(dst_lb); [ -n "$LB" ] || { echo "не резолвится $DST_DNS"; exit 1; } [ $# -gt 0 ] || { echo "укажи loop id"; exit 1; } confirm "Перенесу на $DST: $*." || exit 1 sku() { base64 -d 2>/dev/null | grep -a -o '"sku_short_name":"[a-z]*"' | cut -d'"' -f4; } cr_env() { kubectl --context "$1" -n loop get mattermost "$2" -o json | jq -r '.spec.mattermostEnv[]|"\(.name)=\(.value)"' | grep -v '^MM_LICENSE=' | sort; } move_one() { local ID=$1 echo; echo "################ $ID" if [ "$(token_ok)" -lt 5 ]; then [ -z "${PARALLEL_CHILD:-}" ] || { echo "токен истёк в параллельном режиме"; return 1; } refresh_token || return 1 fi local SRC DOMAIN # источник = куб с CR, registry неактуален for SRC in $SOURCES ""; do [ -n "$SRC" ] || { echo "CR $ID не найден ни на одном исходном кубе"; return 1; } DOMAIN=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o jsonpath='{.spec.ingress.host}' 2>/dev/null) && [ -n "$DOMAIN" ] && break done local REP; REP=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o jsonpath='{.spec.replicas}') # replicas 0 на источнике ок, если уже перенесён (продолжение после сбоя на прокси/DNS) [ "$REP" = 1 ] || [ "$REP" = "${ALLOW_REPLICAS:-}" ] || { [ "$REP" = 0 ] && kubectl --context "$DST" -n loop get mattermost "$ID" >/dev/null 2>&1; } || { echo "на $SRC replicas=$REP (не 1) — такие переносим отдельно (ALLOW_REPLICAS=$REP)"; return 1; } # лишний ingress на тот же сервис (у foxford ff-api-v4-ingress на /api/v4) перехватит проверку прокси и после replicas 0 отдаст 503 local XING; XING=$(kubectl --context "$SRC" -n loop get ingress -o json | jq -r --arg id "$ID" '.items[]|select(.metadata.name!=$id and any(.spec.rules[]?.http.paths[]?; .backend.service.name==$id))|.metadata.name') [ -z "$XING" ] || { echo "на $SRC есть ещё ingress на сервис $ID: $XING — снять до переноса"; return 1; } local VST; VST=$(printf "select status from loop_instance where id like '%%%s' and deleted_at is null;\n" "${ID#loop}" | vsql) || return 1 [ "$VST" = RUNNING ] || { echo "в визоре статус '${VST:-нет записи}', не RUNNING — пропускаем, разбираем отдельно"; return 1; } # реальная база = env в CR, а не секрет: если они расходятся, перенос БД не доведён (так было у ai-wolfs) local HENV HSEC HENV=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o json | jq -r '.spec.mattermostEnv[]?|select(.name=="MM_SQLSETTINGS_DATASOURCE").value' | sed -E 's#.*@([^/]+)/.*#\1#') HSEC=$(kubectl --context "$SRC" -n loop get secret "$ID-db-credentials" -o json | jq -r '.data.DB_CONNECTION_STRING|@base64d' | sed -E 's#.*@([^/]+)/.*#\1#') [ "$HENV" = "$HSEC" ] || [ -n "${ALLOW_DB_MISMATCH:-}" ] || { echo "в CR на $SRC база $HENV, а в секрете $HSEC — перенос БД не доведён, не трогаю (ALLOW_DB_MISMATCH=1, если база в секрете актуальна)"; return 1; } local RESUME="" if kubectl --context "$DST" -n loop get mattermost "$ID" >/dev/null 2>&1; then # CR на $DST от старой попытки, а визор на старом кубе (откат, как ai-wolfs): sync ушёл бы на $SRC local VCL; VCL=$(printf "select ci.name from loop_instance li join cluster_instance ci on ci.id=li.cluster_instance_id where li.id like '%%%s' and li.deleted_at is null;\n" "${ID#loop}" | vsql) || return 1 [ "$VCL" = "$DST" ] || { echo "на $DST есть CR, а в визоре куб $VCL — старый CR на $DST удалить (с бэкапом) и запустить заново"; return 1; } RESUME=1; echo "-- уже есть на $DST, продолжаю с проверок" fi # 1 [ -n "$RESUME" ] || LOOPCTL_YES=1 bash "$HERE/rebind.sh" "$ID" | grep -v -e '^Готово' -e '^Откат визора' || { echo "rebind.sh упал"; return 1; } local VID; VID=$(printf "select id from loop_instance where id like '%%%s' and deleted_at is null;\n" "${ID#loop}" | vsql) || return 1 [ -n "$VID" ] || { echo "не нашёл инстанс в визоре"; return 1; } if [ -z "$RESUME" ] || [ -n "${ALLOW_DB_MISMATCH:-}" ]; then # 2: env из визора, DATASOURCE* подменяем значениями из исходного CR (с ALLOW_DB_MISMATCH — из секрета, CR смотрит не туда) local SRCENV; SRCENV=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o json | jq -c '[.spec.mattermostEnv[]|select(.name|test("^MM_SQLSETTINGS_DATASOURCE(REPLICAS)?$"))|{key:.name,value:.value}]') [ -z "${ALLOW_DB_MISMATCH:-}" ] || SRCENV=$(kubectl --context "$SRC" -n loop get secret "$ID-db-credentials" -o json | jq -c '.data.DB_CONNECTION_STRING|@base64d|[{key:"MM_SQLSETTINGS_DATASOURCE",value:.},{key:"MM_SQLSETTINGS_DATASOURCEREPLICAS",value:.}]') [ "$(jq length <<<"$SRCENV")" -ge 1 ] || { echo "в исходном CR нет DATASOURCE"; return 1; } vapi "$API/$VID" | jq -c --argjson src "$SRCENV" \ '{environmentProperties: ([.environmentProperties[]|select(.key|test("^MM_SQLSETTINGS_DATASOURCE(REPLICAS)?$")|not)|{key,value}] + $src)}' \ | vapi -X PATCH "$API/$VID" -d @- -o /dev/null || { echo "PATCH env в визоре не прошёл (токен? лимиты?)"; return 1; } echo "-- env в визоре обновлены ($(jq -r '[.[].key]|join(", ")' <<<"$SRCENV"))" # свой домен со своим сертификатом: визор создаст CR с wildcard-loop.ru-tls, секрет нужен на $DST до sync local TLS; TLS=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o jsonpath='{.spec.ingress.tlsSecret}') if [ -n "$TLS" ] && [ "$TLS" != wildcard-loop.ru-tls ]; then kubectl --context "$SRC" -n loop get secret "$TLS" -o json \ | jq 'del(.metadata.uid,.metadata.resourceVersion,.metadata.creationTimestamp,.metadata.managedFields,.metadata.ownerReferences,.metadata.annotations)' \ | kubectl --context "$DST" -n loop apply -f - >/dev/null || { echo "tls-секрет $TLS не скопировался"; return 1; } echo "-- свой сертификат $TLS скопирован" fi # 3 vapi -X POST "$API/$VID/synchronization" -o /dev/null || { echo "sync не запустился"; return 1; }; echo "-- sync запущен" local i ok="" # probes как на источнике: визор при создании ставит 30/10, большой инстанс (foxford 600/60) не успеет стартовать for i in $(seq 1 30); do kubectl --context "$DST" -n loop get mattermost "$ID" >/dev/null 2>&1 && break; sleep 2; done kubectl --context "$DST" -n loop patch mattermost "$ID" --type merge \ -p "$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o json | jq -c '{spec:{probes:.spec.probes,ingress:{tlsSecret:.spec.ingress.tlsSecret}}}')" >/dev/null || { echo "probes/tlsSecret не скопировались"; return 1; } for i in $(seq 1 36); do # до 6 минут sleep 10 [ "$(kubectl --context "$DST" -n loop get pods --no-headers 2>/dev/null | grep "^$ID-" | grep -v Terminating | awk '{print $2}' | head -1)" = 1/1 ] && { ok=1; break; } done [ -n "$ok" ] || { echo "под на $DST не поднялся за 6 минут"; return 1; } fi if ! kubectl --context "$DST" -n loop get mattermost "$ID" -o json | jq -e 'any(.spec.mattermostEnv[]?; .name=="MM_LICENSE")' >/dev/null; then echo "-- MM_LICENSE нет, повторный sync"; vapi -X POST "$API/$VID/synchronization" -o /dev/null; sleep 90 fi local ST; ST=$(ping_db "$DOMAIN" "$LB"); [ "$ST" = OK/OK/OK ] || { echo "$DST не здоров: $ST"; return 1; } local L3 LS; L3=$(curl -s -m 10 --resolve "$DOMAIN:443:$LB" "https://$DOMAIN/api/v4/license/client?format=old" | jq -r .SkuShortName) LS=$(kubectl --context "$SRC" -n loop get mattermost "$ID" -o json | jq -r '.spec.mattermostEnv[]|select(.name=="MM_LICENSE").value' | sku) if [ -z "$LS" ]; then # в старом CR нет MM_LICENSE (создан до фикса визора) — берём то, что отдаёт исходный сервер local SIP; SIP=$(kubectl --context "$SRC" -n loop get ingress "$ID" -o jsonpath='{.status.loadBalancer.ingress[0].ip}') LS=$(curl -s -m 10 --resolve "$DOMAIN:443:$SIP" "https://$DOMAIN/api/v4/license/client?format=old" | jq -r .SkuShortName) fi [ "$L3" = "$LS" ] || { echo "лицензия не совпала: $DST=$L3, было=$LS"; return 1; } local D; D=$(diff <(cr_env "$SRC" "$ID") <(cr_env "$DST" "$ID") || true) if [ -n "$D" ]; then echo "env в CR на $DST отличаются от исходного ('<' было на $SRC, '>' стало из визора):"; sed -E 's#://([^:]+):[^@]+@#://\1:***@#g' <<<"$D" | cut -c1-200 [ "${ALLOW_ENV_DIFF:-0}" = 1 ] || { echo "стоп: исходный инстанс не тронут, $DST уже запущен параллельно. Если разница ок — перезапусти с ALLOW_ENV_DIFF=1"; return 1; } fi echo "-- $DST ок: $ST, лицензия $L3, env совпадают" # 4 # на источнике уже 0 (PAUSED: перед переносом статус RUNNING в визоре, после — pause через API): прокси не нужен if [ "$REP" = 0 ]; then echo "-- на $SRC и так replicas 0, прокси не нужен. После переноса: pause в визоре" else bash "$HERE/proxy.sh" "$ID" "$SRC" >/dev/null || { echo "make-proxy упал"; return 1; } local NAME=${DOMAIN%%.*}-proxy; [[ $NAME =~ ^[a-z] ]] || NAME=p$NAME; local PF="$STATE/proxy/$NAME-${SRC//-/}.yaml" local SRCIP; SRCIP=$(kubectl --context "$SRC" -n loop get ingress "$ID" -o jsonpath='{.status.loadBalancer.ingress[0].ip}') [ -n "$SRCIP" ] || { echo "не нашёл IP ingress на $SRC"; return 1; } kubectl --context "$SRC" apply -f "$PF" >/dev/null || { echo "прокси не применился"; return 1; } kubectl --context "$SRC" -n loop patch mattermost "$ID" --type merge -p '{"spec":{"ingress":{"enabled":false}}}' >/dev/null || return 1 sleep 8 ST=$(ping_db "$DOMAIN" "$SRCIP") if [ "$ST" != OK/OK/OK ]; then echo "прокси на $SRC не работает ($ST), возвращаю ingress" kubectl --context "$SRC" -n loop patch mattermost "$ID" --type merge -p '{"spec":{"ingress":{"enabled":true}}}' >/dev/null return 1 fi kubectl --context "$SRC" -n loop patch mattermost "$ID" --type merge -p '{"spec":{"replicas":0}}' >/dev/null || return 1 echo "-- прокси на $SRC работает ($SRCIP), инстанс на $SRC погашен" fi # 5 local NOTE="" if [[ $DOMAIN == *.loop.ru ]]; then LOOPCTL_YES=1 bash "$HERE/../dns/dns.sh" cname "${DOMAIN%.loop.ru}" "$DST_DNS" | tail -1 || { echo "DNS не сменился, сделай руками: loopctl dns cname ${DOMAIN%.loop.ru} $DST_DNS"; return 1; } else NOTE=" ВНИМАНИЕ: DNS у клиента, прокси снимать только после того, как $DOMAIN смотрит на $DST_DNS" echo "-- DNS у клиента: попросить сменить $DOMAIN на CNAME $DST_DNS (сейчас: $(dig +short "$DOMAIN" | head -1)). До этого работает через прокси" fi until mkdir "$STATE/.batch-lock" 2>/dev/null; do sleep 0.2; done # общие файлы: по одному процессу [ "$REP" = 0 ] || cat >> "$STATE/proxy-cleanup.txt" </dev/null || true if [ "$(token_ok)" -lt 8 ]; then refresh_token || exit 1; fi LOGDIR=$STATE/batch-logs/$(date +%Y%m%d-%H%M%S); mkdir -p "$LOGDIR" # "|| echo" как в последовательном режиме: внутри move_one set -e не срабатывает, ошибки ловятся явными проверками for ID in "$@"; do (PARALLEL_CHILD=1 move_one "$ID" || echo "СТОП на $ID") > "$LOGDIR/$ID.log" 2>&1 & done echo "запущено параллельно: $* (логи: $LOGDIR)" wait echo; for ID in "$@"; do if grep -q "^== .* готово" "$LOGDIR/$ID.log"; then echo "OK $ID $(grep -m1 "$DST ок" "$LOGDIR/$ID.log" | cut -c4-)" else echo "FAIL $ID: $(tail -1 "$LOGDIR/$ID.log")"; fi done exit 0 fi DONE=() for ID in "$@"; do move_one "$ID" || { echo; echo "СТОП на $ID. Перенесены: ${DONE[*]:-нет}. Остальные не трогал."; exit 1; } DONE+=("$ID") done echo; echo "Пачка готова: $*"