gendesign/ops/split-infra-postgres.sh
bot-backend efaab2efda
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m16s
chore(ops): адрес Poincare — 188.124.37.140 вместо заменённого 188.246.224.93 (#3110)
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый
188.246.224.93 фильтровался российскими операторами и не открывался ни с
домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет —
проверено с машины владельца после переключения DNS.

Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался
прежний (разбор и восстановление — #3119). Здесь только то, что осталось в
репозитории.

Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh:
скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча
настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты
комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона;
они не исполняются, но именно по ним сверяются при переезде, и разошедшийся
адрес в них дороже, чем кажется.
2026-08-27 11:23:37 +03:00

572 lines
43 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.

#!/usr/bin/env bash
# Перенос баз `forgejo` и `glitchtip` из общего кластера продукта
# (`gendesign-postgres-1`) в лёгкий инфраструктурный (`gendesign-infra-postgres`)
# — раннбук шага 3 переезда Beget → Selectel (#3061).
#
# КОНТЕКСТ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare
# (188.124.37.140), окно 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}"
# Где на самом деле лежит app.ini. Раньше здесь подразумевалось
# "${FORGEJO_DIR}/app.ini", и такого файла на хосте НЕТ: FORGEJO_DIR — каталог
# со стеком (docker-compose.yml, раннеры), а конфиг живёт внутри тома данных,
# который монтируется в контейнер как /data. Проверено на боевом хосте:
# /home/gendesign/forgejo/data/forgejo -> /data
# app.ini = /home/gendesign/forgejo/data/forgejo/gitea/conf/app.ini
# Тихо это ломало страж assert_not_cut_over: он проверяет файл через
# `[[ -r "$ini" ]]`, несуществующий путь читается как "признака нет", и
# половина защиты от повторного --force-restore после cutover просто не
# срабатывала — молча, без единого сообщения.
FORGEJO_APP_INI="${FORGEJO_APP_INI:-}"
if [[ -z "$FORGEJO_APP_INI" ]]; then
for candidate in "${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini" "${FORGEJO_DIR}/data/gitea/conf/app.ini" "${FORGEJO_DIR}/app.ini"
do
[[ -r "$candidate" ]] && { FORGEJO_APP_INI="$candidate"; break; }
done
fi
# Писатели, которых надо погасить на время дампа. Порядок важен только тем, что
# 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_APP_INI" hit=""
# Не нашли app.ini — это НЕ "признака нет". Раньше здесь молча пропускалась
# половина стража, и --force-restore после cutover залил бы устаревший дамп
# поверх живых данных, а сверка фазы 5 подтвердила бы это как успех.
# Лучше остановиться и заставить указать путь явно.
if [[ -z "$ini" || ! -r "$ini" ]]; then
die "не найден app.ini Forgejo — проверить, сделан ли уже cutover, нечем. Искал: ${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini, ${FORGEJO_DIR}/data/gitea/conf/app.ini, ${FORGEJO_DIR}/app.ini. Укажи путь через FORGEJO_APP_INI=... Пропускать эту проверку нельзя: именно она не даёт --force-restore залить устаревший дамп поверх данных, записанных после cutover."
fi
if 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_APP_INI:-${FORGEJO_DIR}/data/forgejo/gitea/conf/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_APP_INI:-${FORGEJO_DIR}/data/forgejo/gitea/conf/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