Files
2026-10-27 14:29:43 +03:00

185 lines
15 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "Пачка готова: $*"