All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 7s
Deploy / deploy-caddy (push) Has been skipped
Deploy / perimeter-smoke (push) Successful in 10s
Deploy / build-backend (push) Successful in 50s
Deploy / build-frontend (push) Successful in 51s
Deploy / build-worker (push) Successful in 53s
Deploy / deploy (push) Successful in 1m43s
Deploy / deploy-status (push) Successful in 1s
547 lines
41 KiB
Bash
Executable file
547 lines
41 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Перенос баз `forgejo` и `glitchtip` из общего кластера продукта
|
||
# (`gendesign-postgres-1`) в лёгкий инфраструктурный (`gendesign-infra-postgres`)
|
||
# — раннбук шага 3 переезда Beget → Selectel (#3061).
|
||
#
|
||
# КОНТЕКСТ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare
|
||
# (188.246.224.93), окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает
|
||
# целиком вместе с томом postgres_data (~15 ГБ), а базы forgejo (265 МБ,
|
||
# git.gendsgn.ru + Actions CI, из которого идёт сам деплой) и glitchtip (729 МБ,
|
||
# errors.gendsgn.ru) обязаны ОСТАТЬСЯ на Beget. Их новый дом — сервис
|
||
# infra-postgres из docker-compose.prod.yml (профиль `infra`), роли и пустые
|
||
# базы в нём заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh.
|
||
# Полный порядок переезда (шаги 1..4) — в шапке сервиса infra-postgres в
|
||
# docker-compose.prod.yml; этот скрипт закрывает ТОЛЬКО шаг 3.
|
||
#
|
||
# ЗАПУСКАЕТСЯ РУКАМИ на Beget, до окна переезда. Не cron, не CI: он гасит
|
||
# git-хост и трекер ошибок, такое не делают по расписанию.
|
||
#
|
||
# bash /opt/gendesign/ops/split-infra-postgres.sh # СУХОЙ ПРОГОН
|
||
# bash /opt/gendesign/ops/split-infra-postgres.sh --apply # реальный перенос
|
||
#
|
||
# ПО УМОЛЧАНИЮ — СУХОЙ ПРОГОН, и это не формальность: без --apply скрипт не
|
||
# создаёт каталогов, не пишет файлов, не останавливает контейнеров и ничего не
|
||
# заливает. Он только ЧИТАЕТ (docker ps / docker inspect / SELECT'ы) и печатает,
|
||
# что сделал бы. Прогнать сухой прогон заранее — способ выяснить про забытый
|
||
# пароль в .env или несовпадение версий днём, а не ночью на заливке дампа.
|
||
#
|
||
# ЧЕГО СКРИПТ НЕ ДЕЛАЕТ СОЗНАТЕЛЬНО:
|
||
# * не переключает приложения на новый кластер (шаг 4 — правка app.ini и .env,
|
||
# печатается в конце);
|
||
# * не удаляет исходные базы — откат обязан оставаться бесплатным;
|
||
# * не удаляет дампы — печатает их пути.
|
||
#
|
||
# ИДЕМПОТЕНТНОСТЬ. Повторный прогон видит уже перенесённую базу (в приёмнике
|
||
# есть таблицы) и НЕ трогает её: молча лить дамп поверх живых данных, в которые
|
||
# после cutover уже пишет приложение, — худшее, что здесь можно сделать.
|
||
# Перезалить осознанно: --force-restore (pg_restore --clean --if-exists). Он
|
||
# отказывается работать, если cutover уже сделан (app.ini / .env указывают на
|
||
# infra-postgres), и требует ввести имена баз руками: после cutover такая
|
||
# заливка затирает данные приложений СТАРЫМ дампом, а сверка фазы 5 сравнивает
|
||
# приёмник с источником и подтверждает это как успех.
|
||
#
|
||
# СТИЛЬ. log()/notify() — общая библиотека ops/lib-backup.sh, та же, что у
|
||
# ops/backup.sh и ops/backup-forgejo.sh. notify() здесь НЕ используется: скрипт
|
||
# интерактивный, у экрана сидит человек, телеграм-алерт ему ничего не добавит.
|
||
|
||
set -euo pipefail
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
# shellcheck source=./lib-backup.sh
|
||
source "$SCRIPT_DIR/lib-backup.sh"
|
||
|
||
# --- конфигурация (переопределяется окружением) --------------------------
|
||
SRC_CONTAINER="${SRC_CONTAINER:-gendesign-postgres-1}"
|
||
DST_CONTAINER="${DST_CONTAINER:-gendesign-infra-postgres}"
|
||
# Роль-владелец у обеих баз совпадает с именем базы (так их завёл initdb-скрипт),
|
||
# поэтому отдельной таблицы соответствий не нужно — везде "$db" в обеих ролях.
|
||
DATABASES="${DATABASES:-forgejo glitchtip}"
|
||
DUMP_DIR="${DUMP_DIR:-/opt/gendesign/backups/migration-3061}"
|
||
FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}"
|
||
# Писатели, которых надо погасить на время дампа. Порядок важен только тем, что
|
||
# forgejo идёт первым: это git и CI, его простой заметнее всего.
|
||
FORGEJO_CONTAINER="${FORGEJO_CONTAINER:-forgejo}"
|
||
GLITCHTIP_CONTAINERS="${GLITCHTIP_CONTAINERS:-glitchtip-web glitchtip-worker}"
|
||
# Соответствие «база → её писатели». Нужно затем, что ненайденный писатель
|
||
# блокирует дамп ИМЕННО своей базы: снимать дамп с базы, в которую продолжают
|
||
# писать, нельзя (см. проверку MISSING_WRITERS ниже).
|
||
declare -A DB_WRITERS=(
|
||
[forgejo]="$FORGEJO_CONTAINER"
|
||
[glitchtip]="$GLITCHTIP_CONTAINERS"
|
||
)
|
||
# Файл окружения продукта — читается только на предмет «cutover уже сделан?».
|
||
GENDESIGN_ENV="${GENDESIGN_ENV:-/opt/gendesign/.env}"
|
||
# Запас свободного места сверх суммарного размера баз. Дамп -Fc сжат и заведомо
|
||
# меньше базы, так что требование «влезет ещё одна полная копия» консервативно.
|
||
DISK_MARGIN_PCT="${DISK_MARGIN_PCT:-30}"
|
||
|
||
APPLY=0
|
||
FORCE_RESTORE=0
|
||
ALLOW_MISSING_WRITER=0
|
||
|
||
# --- разбор аргументов ----------------------------------------------------
|
||
usage() {
|
||
cat <<'USAGE'
|
||
Использование: bash ops/split-infra-postgres.sh [--apply] [--force-restore] [--dump-dir DIR]
|
||
|
||
(без флагов) сухой прогон: только чтение и печать плана, ноль побочных эффектов
|
||
--apply реально останавливает писателей, снимает дампы и восстанавливает
|
||
--force-restore перезалить базу, в которой на приёмнике УЖЕ есть таблицы
|
||
(pg_restore --clean --if-exists). Только вместе с --apply.
|
||
Спросит подтверждение и откажется работать после cutover.
|
||
--allow-missing-writer
|
||
снять дамп базы, писатель которой не найден среди запущенных
|
||
контейнеров. По умолчанию это ОШИБКА: чаще всего контейнер
|
||
просто называется иначе и продолжает писать.
|
||
--dump-dir DIR куда класть дампы (по умолчанию /opt/gendesign/backups/migration-3061)
|
||
USAGE
|
||
}
|
||
|
||
while (( $# > 0 )); do
|
||
case "$1" in
|
||
--apply) APPLY=1 ;;
|
||
--force-restore) FORCE_RESTORE=1 ;;
|
||
--allow-missing-writer) ALLOW_MISSING_WRITER=1 ;;
|
||
--dump-dir) shift; [[ $# -gt 0 ]] || { usage >&2; exit 2; }; DUMP_DIR="$1" ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 2 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
die() { log "ОШИБКА: $*" >&2; exit 1; }
|
||
phase() { log "──────── $* ────────"; }
|
||
# Единая пометка строк, которые в сухом прогоне ТОЛЬКО печатаются.
|
||
plan() { log " [сухой прогон] сделал бы: $*"; }
|
||
|
||
if (( FORCE_RESTORE == 1 && APPLY == 0 )); then
|
||
die "--force-restore без --apply бессмысленен: в сухом прогоне заливать нечего."
|
||
fi
|
||
|
||
# --- стражи --force-restore ----------------------------------------------
|
||
# pg_restore --clean --if-exists дропает объекты и льёт их заново из дампа. Если
|
||
# приложения УЖЕ переключены на приёмник, это уничтожает всё, что они записали
|
||
# после cutover — а сверка фазы 5 такую заливку ПОДТВЕРДИТ как успех: она
|
||
# сравнивает источник с приёмником, и записей, сделанных после cutover, в
|
||
# источнике никогда не было. Поэтому единственная защита — не дать запуститься.
|
||
assert_not_cut_over() {
|
||
local ini="${FORGEJO_DIR}/app.ini" hit=""
|
||
if [[ -r "$ini" ]] && grep -Eq '^[[:space:]]*HOST[[:space:]]*=[[:space:]]*infra-postgres' "$ini"; then
|
||
hit+="${ini} (HOST = infra-postgres); "
|
||
fi
|
||
if [[ -r "$GENDESIGN_ENV" ]] && grep -Eq '^[[:space:]]*GLITCHTIP_DB_HOST[[:space:]]*=[[:space:]]*infra-postgres' "$GENDESIGN_ENV"; then
|
||
hit+="${GENDESIGN_ENV} (GLITCHTIP_DB_HOST=infra-postgres); "
|
||
fi
|
||
[[ -z "$hit" ]] || die \
|
||
"cutover уже выполнен — ${hit}приложения пишут в ${DST_CONTAINER}. --force-restore залил бы поверх их данных УСТАРЕВШИЙ дамп из ${SRC_CONTAINER}, и сверка фазы 5 подтвердила бы это как успех. Если перезалить всё же необходимо: верните HOST/GLITCHTIP_DB_HOST на postgres, погасите писателей, снимите ОТДЕЛЬНЫЙ бэкап приёмника — и только тогда повторяйте."
|
||
}
|
||
|
||
# Второй рубеж: осознанность. Флаг легко скопировать из раннбука не думая,
|
||
# а ввод имён баз руками — нет.
|
||
confirm_force_restore() { # confirm_force_restore <базы через пробел>
|
||
local dbs answer=""
|
||
dbs="$(tr -s ' ' <<< "$1" | sed 's/^ *//; s/ *$//')"
|
||
log "--force-restore ПЕРЕЗАЛЬЁТ в ${DST_CONTAINER} базы: ${dbs}"
|
||
log "Их нынешнее содержимое в приёмнике будет удалено, отката у этого шага нет."
|
||
printf 'Введите имена баз ровно как выше для подтверждения: ' >&2
|
||
read -r answer || true
|
||
[[ "$(tr -s ' ' <<< "$answer" | sed 's/^ *//; s/ *$//')" == "$dbs" ]] \
|
||
|| die "подтверждение не совпало — ничего не тронуто."
|
||
}
|
||
|
||
# --- вспомогательные запросы ---------------------------------------------
|
||
# Ходим ровно тем же доступом, которым потом пойдут pg_dump/pg_restore: psql под
|
||
# прикладной ролью через `docker exec` (внутри контейнера это unix-сокет, на нём
|
||
# в официальном образе trust — пароль не нужен). Так проверка не расходится с
|
||
# делом: до чего не достучалась она, там и дамп не снимется.
|
||
q() { # q <контейнер> <база/роль> <sql>
|
||
docker exec "$1" psql -U "$2" -d "$2" -tAc "$3" 2>/dev/null
|
||
}
|
||
|
||
# Построчные счётчики по ФАКТИЧЕСКИМ таблицам: имена берутся из системного
|
||
# каталога, а не из зашитого списка — выдуманное имя таблицы превратило бы
|
||
# сверку в самообман. count(*) считается честно (не reltuples из pg_class: там
|
||
# оценка планировщика, после свежего восстановления она вообще нулевая до
|
||
# ANALYZE), через query_to_xml — так один запрос возвращает счётчики по всем
|
||
# таблицам сразу, без генерации SQL на стороне bash.
|
||
# relkind='r' — обычные таблицы. Партиционированные родители ('p') намеренно
|
||
# исключены из СЧЁТЧИКОВ (их count(*) продублировал бы строки партиций), но
|
||
# учитываются в числе таблиц ниже. В forgejo/glitchtip партиций нет — это
|
||
# страховка на будущее, а не описание сегодняшнего дня.
|
||
SNAP_SQL="
|
||
SELECT n.nspname || '.' || c.relname || E'\t' ||
|
||
(xpath('/row/c/text()',
|
||
query_to_xml(format('SELECT count(*) AS c FROM %I.%I', n.nspname, c.relname),
|
||
false, true, '')))[1]::text
|
||
FROM pg_class c
|
||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema')
|
||
ORDER BY 1;"
|
||
|
||
TABLES_SQL="
|
||
SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||
WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema');"
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 1. Preflight — всё, что можно проверить, не тронув ничего
|
||
# ==========================================================================
|
||
phase "ФАЗА 1: preflight"
|
||
if (( APPLY == 1 )); then
|
||
log "РЕЖИМ: --apply. Писатели будут остановлены, дампы сняты и восстановлены."
|
||
else
|
||
log "РЕЖИМ: сухой прогон (по умолчанию). Ни одного побочного эффекта: ни mkdir,"
|
||
log " ни docker stop, ни записи в файл. Для реального переноса — --apply."
|
||
fi
|
||
log "Источник: ${SRC_CONTAINER} → приёмник: ${DST_CONTAINER}; базы: ${DATABASES}"
|
||
|
||
command -v docker >/dev/null 2>&1 || die "docker не найден в PATH."
|
||
|
||
# Источник и приёмник обязаны быть РАЗНЫМИ контейнерами. При совпадении весь
|
||
# preflight проходит (контейнер запущен, healthy, роли и базы на месте), а
|
||
# pg_restore --clean --if-exists бьёт по единственной живой копии баз.
|
||
[[ "$SRC_CONTAINER" != "$DST_CONTAINER" ]] || die \
|
||
"источник и приёмник совпадают (${SRC_CONTAINER}). Проверьте SRC_CONTAINER / DST_CONTAINER: перенос базы в саму себя с --force-restore разрушил бы единственную копию."
|
||
|
||
(( FORCE_RESTORE == 0 )) || assert_not_cut_over
|
||
|
||
running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)"
|
||
for c in "$SRC_CONTAINER" "$DST_CONTAINER"; do
|
||
grep -qxF -- "$c" <<< "$running" \
|
||
|| die "контейнер ${c} не запущен. Запущены: $(tr -s '[:space:]' ' ' <<< "$running")"
|
||
log " ${c}: запущен"
|
||
done
|
||
|
||
# Приёмник обязан быть healthy — его healthcheck намеренно требует наличия ОБЕИХ
|
||
# баз (разбор «отравленного тома» — в шапке сервиса infra-postgres в
|
||
# docker-compose.prod.yml). Красный healthcheck здесь = initdb-скрипт не
|
||
# отработал, и лечится это пересозданием тома, а не заливкой дампа.
|
||
dst_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DST_CONTAINER" 2>/dev/null || echo unknown)"
|
||
[[ "$dst_health" == "healthy" ]] || die \
|
||
"приёмник ${DST_CONTAINER} в состоянии '${dst_health}', а не healthy. Его healthcheck требует наличия обеих баз — красный статус значит, что initdb-скрипт не отработал (чаще всего пуст FORGEJO_DB_PASS или GLITCHTIP_DB_PASS в /opt/gendesign/.env). Лечение: заполнить пароли, ПЕРЕСОЗДАТЬ том gendesign_infra_postgres_data и поднять кластер заново (команда — в шапке docker-compose.prod.yml). Дамп в такой кластер лить нельзя."
|
||
log " ${DST_CONTAINER}: healthy"
|
||
|
||
# Мажорная версия. postgres:16-alpine против postgis/postgis:16-3.4 — обе 16,
|
||
# формат дампа совместим один-в-один. Расхождение мажора означало бы, что
|
||
# кто-то поменял образ, и pg_restore из старшей в младшую просто не пройдёт.
|
||
src_ver="$(docker exec "$SRC_CONTAINER" psql -U postgres -tAc 'SHOW server_version_num' 2>/dev/null | tr -dc '0-9' || true)"
|
||
[[ -n "$src_ver" ]] || src_ver="$(q "$SRC_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)"
|
||
dst_ver="$(q "$DST_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)"
|
||
[[ -n "$src_ver" && -n "$dst_ver" ]] || die "не удалось прочитать server_version_num (src='${src_ver}', dst='${dst_ver}')."
|
||
src_major=$(( src_ver / 10000 )); dst_major=$(( dst_ver / 10000 ))
|
||
(( src_major == dst_major )) || die \
|
||
"мажорные версии PostgreSQL не совпадают: источник ${src_major}, приёмник ${dst_major}. Перенос дампом между мажорами этим скриптом не поддержан — сначала выровняйте образы."
|
||
log " версии совпадают: PostgreSQL ${src_major} в обоих кластерах"
|
||
|
||
# Роли и базы в приёмнике. Успешный коннект ролью $db в базу $db разом
|
||
# доказывает и существование роли, и существование базы, и право входа —
|
||
# то есть ровно те три вещи, на которых упало бы восстановление.
|
||
total_bytes=0
|
||
declare -A ALREADY=() SRC_TABLES=()
|
||
for db in $DATABASES; do
|
||
src_t="$(q "$SRC_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)"
|
||
[[ -n "$src_t" ]] || die "в источнике ${SRC_CONTAINER} база ${db} под ролью ${db} недоступна."
|
||
(( src_t > 0 )) || die "в источнике ${SRC_CONTAINER} база ${db} ПУСТА (0 таблиц) — переносить нечего, проверьте имя контейнера."
|
||
SRC_TABLES["$db"]="$src_t"
|
||
|
||
dst_t="$(q "$DST_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)"
|
||
[[ -n "$dst_t" ]] || die \
|
||
"в приёмнике ${DST_CONTAINER} нет роли ${db} и/или базы ${db}. Их заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh, и только на ПУСТОМ томе. Лечение — пересоздать том gendesign_infra_postgres_data и поднять кластер заново с заполненными паролями."
|
||
ALREADY["$db"]="$dst_t"
|
||
|
||
size="$(q "$SRC_CONTAINER" "$db" "SELECT pg_database_size('${db}')" | tr -dc '0-9' || echo 0)"
|
||
total_bytes=$(( total_bytes + size ))
|
||
if (( dst_t > 0 )); then
|
||
log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); в приёмнике УЖЕ ${dst_t} таблиц"
|
||
else
|
||
log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); приёмник пуст — заготовка на месте"
|
||
fi
|
||
done
|
||
|
||
# Место на диске. Каталога дампов может ещё не быть (в сухом прогоне мы его и не
|
||
# создаём) — меряем ближайшего существующего родителя, файловая система у них
|
||
# одна и та же.
|
||
probe_dir="$DUMP_DIR"
|
||
while [[ ! -d "$probe_dir" && "$probe_dir" != "/" ]]; do probe_dir="$(dirname "$probe_dir")"; done
|
||
free_bytes=$(( $(df -Pk "$probe_dir" | awk 'NR==2{print $4}') * 1024 ))
|
||
need_bytes=$(( total_bytes + total_bytes * DISK_MARGIN_PCT / 100 ))
|
||
log " место в ${probe_dir}: свободно $(( free_bytes / 1024 / 1024 )) МБ, нужно ≥ $(( need_bytes / 1024 / 1024 )) МБ (сумма баз + ${DISK_MARGIN_PCT}%)"
|
||
(( free_bytes >= need_bytes )) || die "недостаточно места под дампы в ${probe_dir}."
|
||
|
||
# Писатели. Ненайденный контейнер трактуется как ОПАСНОСТЬ, а не как удобство:
|
||
# «уже погашен» и «называется иначе и пишет прямо сейчас» с этой стороны
|
||
# выглядят одинаково, а последствия у них противоположные.
|
||
WRITERS="$FORGEJO_CONTAINER $GLITCHTIP_CONTAINERS"
|
||
declare -A MISSING_WRITERS=()
|
||
for c in $WRITERS; do
|
||
if grep -qxF -- "$c" <<< "$running"; then
|
||
log " писатель ${c}: запущен, будет остановлен"
|
||
continue
|
||
fi
|
||
log " писатель ${c}: НЕ найден среди запущенных"
|
||
for db in $DATABASES; do
|
||
if [[ " ${DB_WRITERS[$db]:-} " == *" $c "* ]]; then
|
||
MISSING_WRITERS["$db"]+="${c} "
|
||
fi
|
||
done
|
||
done
|
||
|
||
# Все базы уже на месте → дампить и заливать нечего, остаётся сверка.
|
||
pending=""
|
||
# `|| true` обязателен: под `set -e` ложное (( )) в конце тела цикла уронило бы
|
||
# весь скрипт ровно в счастливом случае «всё уже перенесено».
|
||
for db in $DATABASES; do
|
||
(( ${ALREADY[$db]} == 0 || FORCE_RESTORE == 1 )) && pending+="${db} " || true
|
||
done
|
||
if [[ -z "$pending" ]]; then
|
||
log "ВСЕ базы уже перенесены (в приёмнике есть таблицы). Останов писателей и"
|
||
log "дамп пропускаю — повторный прогон ничего не затирает. Перехожу к сверке."
|
||
log "Осознанно перезалить: --apply --force-restore."
|
||
else
|
||
log "К переносу: ${pending}"
|
||
# Дамп базы, писатель которой не погашен, теряет всё записанное после
|
||
# снимка, и сверка фазы 5 этого НЕ покажет: слева в ней стоит замороженный
|
||
# SNAP_BEFORE, снятый в тот же момент, что и дамп, справа — приёмник; при
|
||
# любом объёме дозаписи они совпадут. Единственный шанс поймать — здесь.
|
||
for db in $pending; do
|
||
miss="${MISSING_WRITERS[$db]:-}"
|
||
[[ -n "$miss" ]] || continue
|
||
if (( ALLOW_MISSING_WRITER == 1 )); then
|
||
log " WARN: писатели базы ${db} не найдены (${miss}) — продолжаю по --allow-missing-writer. Ответственность за то, что в ${db} никто не пишет, на вас."
|
||
else
|
||
die "писатели базы ${db} не найдены среди запущенных: ${miss}. Скорее всего контейнер называется иначе (посмотрите docker ps и переопределите FORGEJO_CONTAINER / GLITCHTIP_CONTAINERS) — тогда он продолжает писать, и всё записанное после дампа потеряется на cutover молча. Если писатель действительно погашен заранее — повторите с --allow-missing-writer."
|
||
fi
|
||
done
|
||
# Подтверждение перезаливки — только для баз, которые реально будут дропнуты.
|
||
if (( FORCE_RESTORE == 1 )); then
|
||
clean_dbs=""
|
||
for db in $pending; do
|
||
(( ${ALREADY[$db]} > 0 )) && clean_dbs+="${db} " || true
|
||
done
|
||
[[ -z "$clean_dbs" ]] || confirm_force_restore "$clean_dbs"
|
||
fi
|
||
fi
|
||
|
||
ts="$(date -u +'%Y%m%d_%H%M%S')"
|
||
RUN_DIR="${DUMP_DIR}/${ts}"
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 2. Останов писателей
|
||
# ==========================================================================
|
||
if [[ -n "$pending" ]]; then
|
||
phase "ФАЗА 2: останов писателей"
|
||
# Гасим ИМЕННО контейнеры, а не `docker compose stop`: Forgejo живёт своим
|
||
# стеком в /home/gendesign/forgejo (в этом репозитории его compose-файла
|
||
# нет), GlitchTip — в стеке продукта, и угадывать имена сервисов в двух
|
||
# разных компоузах незачем — имя контейнера здесь однозначно и стабильно
|
||
# (container_name зафиксирован в docker-compose.prod.yml).
|
||
# Зачем вообще гасить: дамп с живого писателя даёт консистентный снимок
|
||
# (pg_dump работает в одной транзакции), но всё, что приложение запишет
|
||
# ПОСЛЕ снимка, до нового кластера не доедет и потеряется на cutover.
|
||
for c in $WRITERS; do
|
||
if ! grep -qxF -- "$c" <<< "$running"; then
|
||
log " ${c}: не запущен — пропускаю"
|
||
continue
|
||
fi
|
||
if (( APPLY == 1 )); then
|
||
log " останавливаю ${c}"
|
||
docker stop "$c" >/dev/null || die "не смог остановить ${c}."
|
||
still="$(docker ps --format '{{.Names}}' | grep -cxF -- "$c" || true)"
|
||
(( still == 0 )) || die "${c} всё ещё в docker ps после stop."
|
||
log " ${c}: остановлен"
|
||
else
|
||
plan "docker stop ${c}"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 3. Снимок «до» и дампы
|
||
# ==========================================================================
|
||
declare -A SNAP_BEFORE=() DUMP_FILE=()
|
||
if [[ -n "$pending" ]]; then
|
||
phase "ФАЗА 3: снимок «до» и дампы"
|
||
if (( APPLY == 1 )); then
|
||
# umask 077: в дампе forgejo — хеши паролей, access-токены и секреты
|
||
# вебхуков, в glitchtip — DSN и тела событий. Дампы намеренно НЕ
|
||
# удаляются и попадают в общий бэкап каталога backups, значит лежать они
|
||
# обязаны нечитаемыми для прочих пользователей хоста.
|
||
umask 077
|
||
mkdir -p "$RUN_DIR"
|
||
chmod 700 "$RUN_DIR" # каталог мог существовать с прошлого прогона
|
||
log "Каталог дампов: ${RUN_DIR}"
|
||
else
|
||
plan "mkdir -p ${RUN_DIR}"
|
||
fi
|
||
|
||
for db in $pending; do
|
||
# Снимок снимается ПОСЛЕ останова писателей — иначе счётчики поехали бы
|
||
# между «до» и «после» по совершенно законной причине, и сверка перестала
|
||
# бы что-либо доказывать.
|
||
SNAP_BEFORE["$db"]="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)"
|
||
[[ -n "${SNAP_BEFORE[$db]}" ]] || die "не смог снять счётчики по базе ${db} в источнике."
|
||
log " ${db}: снимок «до» — таблиц $(grep -c . <<< "${SNAP_BEFORE[$db]}"), строк всего $(awk -F'\t' '{s+=$2} END{print s+0}' <<< "${SNAP_BEFORE[$db]}")"
|
||
|
||
f="${RUN_DIR}/${db}_${ts}.dump"
|
||
DUMP_FILE["$db"]="$f"
|
||
if (( APPLY == 1 )); then
|
||
log " снимаю дамп ${db} → ${f}"
|
||
# -Fc: собственный формат, сжат, восстанавливается pg_restore с
|
||
# --no-owner. --no-owner/--no-privileges уже на дампе: в новом кластере
|
||
# ни ролей-хозяев чужих объектов, ни исходных ACL воспроизводить не
|
||
# нужно — владельцем станет роль, которой мы восстанавливаем.
|
||
docker exec "$SRC_CONTAINER" pg_dump -U "$db" -d "$db" -Fc --no-owner --no-privileges > "$f" \
|
||
|| die "pg_dump базы ${db} упал; файл ${f} оставлен для разбора."
|
||
chmod 600 "$f"
|
||
[[ -s "$f" ]] || die "дамп ${db} нулевого размера → ${f}."
|
||
# Дамп -Fc бинарный, gzip-проверка verify_dump_integrity() из
|
||
# lib-backup.sh к нему неприменима. Эквивалент по смыслу — прочитать
|
||
# оглавление: битый/обрезанный архив на этом и споткнётся, а базы
|
||
# такое чтение не касается вовсе.
|
||
docker exec -i "$DST_CONTAINER" pg_restore -l < "$f" > /dev/null \
|
||
|| die "дамп ${db} не читается pg_restore -l (обрезан?) → ${f}."
|
||
log " ${db}: дамп OK, $(du -h "$f" | cut -f1) ($(wc -c < "$f") байт)"
|
||
# Контрольный пересъём. Проверка писателей выше ловит «контейнер
|
||
# назван иначе», но не ловит писателя вне docker (ручной psql,
|
||
# забытый воркер на хосте, второй экземпляр). Если источник изменился
|
||
# между SNAP_BEFORE и этой строкой — дамп уже неполон, а сверка фазы
|
||
# 5 сравнивает приёмник именно с SNAP_BEFORE и промолчит.
|
||
snap_after_dump="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)"
|
||
if [[ "$snap_after_dump" != "${SNAP_BEFORE[$db]}" ]]; then
|
||
diff <(printf '%s\n' "${SNAP_BEFORE[$db]}") <(printf '%s\n' "$snap_after_dump") | sed -n '1,20p' >&2 || true
|
||
die "источник ${db} изменился во время дампа — в него кто-то ещё пишет (см. расхождение выше). Найдите и погасите писателя, затем повторите; дамп ${f} использовать нельзя."
|
||
fi
|
||
else
|
||
plan "pg_dump -U ${db} -d ${db} -Fc из ${SRC_CONTAINER} → ${f}"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 4. Восстановление ЦЕЛЕВОЙ РОЛЬЮ
|
||
# ==========================================================================
|
||
if [[ -n "$pending" ]]; then
|
||
phase "ФАЗА 4: восстановление"
|
||
# ПОЧЕМУ РОЛЬЮ, А НЕ СУПЕРЮЗЕРОМ — разбор в шапке
|
||
# ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh: дамп снят с
|
||
# --no-owner, то есть владельцем восстановленных объектов становится роль,
|
||
# ОТ ИМЕНИ КОТОРОЙ идёт восстановление. Суперюзер забрал бы себе все
|
||
# таблицы, и приложение получило бы "permission denied for table" на первой
|
||
# же записи — уже после cutover, когда откатываться поздно.
|
||
for db in $pending; do
|
||
f="${DUMP_FILE[$db]}"
|
||
restore_args=( -U "$db" -d "$db" --no-owner --no-privileges )
|
||
if (( ${ALREADY[$db]} > 0 )); then
|
||
(( FORCE_RESTORE == 1 )) || die "внутренняя ошибка: ${db} не должна была попасть в очередь."
|
||
log " ${db}: в приёмнике уже ${ALREADY[$db]} таблиц, --force-restore → добавляю --clean --if-exists"
|
||
restore_args+=( --clean --if-exists )
|
||
fi
|
||
if (( APPLY == 0 )); then
|
||
plan "pg_restore ${restore_args[*]} < ${f} (в ${DST_CONTAINER}, ролью ${db}, НЕ суперюзером)"
|
||
continue
|
||
fi
|
||
rlog="${RUN_DIR}/${db}_restore.log"
|
||
rc=0
|
||
docker exec -i "$DST_CONTAINER" pg_restore "${restore_args[@]}" < "$f" > "$rlog" 2>&1 || rc=$?
|
||
errors=$(grep -c '^pg_restore: error' "$rlog" 2>/dev/null || true); errors=${errors:-0}
|
||
warns=$(grep -c '^pg_restore: warning' "$rlog" 2>/dev/null || true); warns=${warns:-0}
|
||
log " ${db}: pg_restore rc=${rc}, ошибок ${errors}, предупреждений ${warns}, лог ${rlog}"
|
||
if (( errors > 0 || rc != 0 )); then
|
||
sed -n '1,20p' "$rlog" >&2
|
||
log "ПОДСКАЗКА: 'permission denied to create extension' — недоверенное расширение владелец базы поставить не может. Лечится разово: создать его суперюзером кластера и повторить прогон с --force-restore." >&2
|
||
die "восстановление ${db} прошло с ошибками — см. ${rlog}. Исходная база НЕ тронута, откат бесплатный."
|
||
fi
|
||
# ANALYZE не для красоты: после восстановления статистики нет вообще, и
|
||
# первый же запрос приложения пошёл бы по плану, построенному вслепую.
|
||
docker exec "$DST_CONTAINER" psql -U "$db" -d "$db" -qc 'ANALYZE' >/dev/null 2>&1 \
|
||
|| log " WARN: ANALYZE по ${db} не прошёл — не блокирует, но прогоните вручную"
|
||
done
|
||
fi
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 5. Сверка до/после
|
||
# ==========================================================================
|
||
phase "ФАЗА 5: сверка"
|
||
mismatch=0
|
||
for db in $DATABASES; do
|
||
# В сухом прогоне сверять нечего у тех баз, которые ещё предстоит перенести:
|
||
# приёмник пуст по определению, расхождение было бы шумом, а не находкой.
|
||
if (( APPLY == 0 )) && [[ " $pending " == *" $db "* ]]; then
|
||
log " ${db}: сухой прогон — дамп не заливался, сверять нечего."
|
||
log " ${db}: в источнике таблиц ${SRC_TABLES[$db]}"
|
||
continue
|
||
fi
|
||
before="${SNAP_BEFORE[$db]:-}"
|
||
# Для базы, которую этот прогон не переносил (уже была на месте), «до» берём
|
||
# из источника прямо сейчас: старый кластер мы не трогали, счётчики в нём —
|
||
# это и есть эталон на момент переноса, пока приложение не переключено.
|
||
# ⚠️ После cutover эталоном они быть перестают: пишут уже в новый кластер,
|
||
# и расхождение здесь будет означать не потерю, а нормальную работу.
|
||
[[ -n "$before" ]] || before="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)"
|
||
after="$(q "$DST_CONTAINER" "$db" "$SNAP_SQL" || true)"
|
||
b_t=$(grep -c . <<< "$before" || true); a_t=$(grep -c . <<< "$after" || true)
|
||
b_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$before")
|
||
a_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$after")
|
||
log " ${db}: таблиц ${b_t} → ${a_t}; строк ${b_r} → ${a_r}"
|
||
if [[ "$before" == "$after" ]]; then
|
||
log " ${db}: СОВПАДАЕТ построчно по всем таблицам"
|
||
else
|
||
mismatch=1
|
||
log " ${db}: РАСХОЖДЕНИЕ (слева источник, справа приёмник):" >&2
|
||
diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | sed -n '1,40p' >&2 || true
|
||
fi
|
||
done
|
||
(( mismatch == 0 )) || die "сверка не сошлась — НЕ переключайте приложения. Исходные базы целы, откат = просто запустить контейнеры обратно."
|
||
log "Сверка возражений не имеет."
|
||
|
||
# ==========================================================================
|
||
# ФАЗА 6. Что дальше — РУКАМИ
|
||
# ==========================================================================
|
||
phase "ФАЗА 6: ручные шаги (скрипт их НЕ делает)"
|
||
cat <<MANUAL
|
||
1. Forgejo: в ${FORGEJO_DIR}/app.ini заменить
|
||
HOST = postgres:5432 → HOST = infra-postgres:5432
|
||
(compose Forgejo лежит там же, вне этого репозитория).
|
||
2. GlitchTip: в /opt/gendesign/.env выставить
|
||
GLITCHTIP_DB_HOST=infra-postgres
|
||
3. ОБЯЗАТЕЛЬНО, тем же движением: в /etc/default/gendesign-backup-forgejo
|
||
PG_CONTAINER=gendesign-infra-postgres
|
||
С момента заливки дампа непустая база forgejo лежит в ДВУХ запущенных
|
||
контейнерах, а автоопределение в ops/backup-forgejo.sh на такую
|
||
неоднозначность намеренно останавливается с exit 1 — то есть ночной бэкап
|
||
git-хоста перестанет сниматься вовсе, и заметить это некому (канал
|
||
оповещений check-backup-staleness.sh на этом хосте не настроен).
|
||
Порядок из шапки docker-compose.prod.yml: ДО переключения приложений там
|
||
стоит PG_CONTAINER=gendesign-postgres-1 (боевой ещё старый), СЕЙЧАС, вместе
|
||
с шагами 1-2, значение меняется на gendesign-infra-postgres, а после
|
||
гашения старого кластера строка убирается совсем.
|
||
4. Поднять писателей обратно:
|
||
cd ${FORGEJO_DIR} && docker compose up -d
|
||
docker start ${GLITCHTIP_CONTAINERS}
|
||
5. Проверить руками: git push в тестовый репозиторий, вход в errors.gendsgn.ru,
|
||
и ночной ops/backup-forgejo.sh — что он выбрал нужный контейнер.
|
||
|
||
ОТКАТ (пока старые базы живы — то есть всегда до шага «удаление» ниже):
|
||
a. вернуть HOST = postgres:5432 в ${FORGEJO_DIR}/app.ini;
|
||
b. вернуть GLITCHTIP_DB_HOST=postgres (или убрать строку — дефолт postgres);
|
||
c. вернуть PG_CONTAINER=gendesign-postgres-1 в конфиге бэкапа Forgejo;
|
||
d. перезапустить оба приложения.
|
||
Снятие дампов исходный кластер не меняло, поэтому откат полный и бесплатный.
|
||
Его единственная цена — записи, сделанные в НОВОМ кластере после cutover.
|
||
MANUAL
|
||
|
||
if (( APPLY == 1 )) && [[ -d "$RUN_DIR" ]]; then
|
||
log "Дампы НЕ удалены (и удалять их скрипт не будет) — лежат в ${RUN_DIR}:"
|
||
ls -1 "$RUN_DIR" | sed 's/^/ /'
|
||
fi
|
||
|
||
log " "
|
||
log "ПОСЛЕДНИЙ ШАГ, НЕ СЕЙЧАС: после того как Forgejo и GlitchTip несколько дней"
|
||
log "проработали на новом кластере и вы этому верите — убрать устаревшие копии из"
|
||
log "старого, иначе они уедут на Selectel и там будут выглядеть настоящими:"
|
||
log " docker exec -it ${SRC_CONTAINER} psql -U postgres -c 'DROP DATABASE forgejo'"
|
||
log " docker exec -it ${SRC_CONTAINER} psql -U postgres -c 'DROP DATABASE glitchtip'"
|
||
log "Скрипт этого не делает и делать не будет: пока старые базы целы, откат бесплатен."
|
||
if (( APPLY == 0 )); then
|
||
log " "
|
||
log "Это был СУХОЙ ПРОГОН: ничего не изменено. Реальный перенос — с --apply."
|
||
fi
|