This commit is contained in:
2026-10-27 14:29:43 +03:00
commit 1769946c99
24 changed files with 3762 additions and 0 deletions
Executable
+184
View File
@@ -0,0 +1,184 @@
#!/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/<время>/<id>.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" <<EOF
$DOMAIN ($ID), $SRC, batch-move
CNAME: $(date '+%Y-%m-%d %H:%M')
снимать после: $(date -v+1H '+%Y-%m-%d %H:%M') (не раньше)
$NOTE
снять прокси:
сначала: loopctl proxy traffic $SRC (трафика нет), потом: kubectl --context $SRC delete -f $PF
EOF
rmdir "$STATE/.batch-lock"
echo "== $DOMAIN готово"
}
if [ "${PARALLEL:-0}" = 1 ]; then
# параллельно, не больше 10: токен обновляем один раз заранее, у каждого инстанса свой лог
[ $# -le 10 ] || { echo "параллельно не больше 10 за раз"; exit 1; }
rmdir "$STATE/.batch-lock" 2>/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 "Пачка готова: $*"