From be2e07d9c3f4d666b6a301b5a0bd36c7dd26b90d Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 24 Aug 2026 16:31:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D0=BB=D1=91=D0=B3=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20Postgres=20=D0=BD=D0=B0=20Beget=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=20forgejo=20=D0=B8=20glitchtip=20(#3080)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/deploy-infra.yml | 28 + docker-compose.prod.yml | 265 ++++++++- ops/backup-forgejo.sh | 121 +++- .../infra-postgres/01-roles-and-databases.sh | 178 ++++++ ops/gendesign-backup-forgejo.default.example | 42 +- ops/split-infra-postgres.sh | 547 ++++++++++++++++++ 6 files changed, 1169 insertions(+), 12 deletions(-) create mode 100755 ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh create mode 100755 ops/split-infra-postgres.sh diff --git a/.forgejo/workflows/deploy-infra.yml b/.forgejo/workflows/deploy-infra.yml index 5fb3df26..13c7a665 100644 --- a/.forgejo/workflows/deploy-infra.yml +++ b/.forgejo/workflows/deploy-infra.yml @@ -94,6 +94,34 @@ jobs: echo "⚠ контейнер caddy не найден — пропускаю reload" fi + # infra-postgres (#3061): применить изменения конфигурации сервиса. + # После разъезда хостов этот workflow — ЕДИНСТВЕННОЕ, что доставляет + # docker-compose.prod.yml на Beget, а лёгкий кластер БД описан именно + # там. Без этой строки любой будущий бамп postgres:16-alpine, правка + # mem_limit или healthcheck'а легли бы в main, workflow отрапортовал + # бы «дерево синхронизировано», а контейнер продолжил бы жить со + # старой конфигурацией по `restart: unless-stopped` — молча, ровно + # класс #2887, ради которого этот workflow и написан. + # + # `up -d` с ЯВНЫМ именем сервиса, а не общий: трогается только + # infra-postgres, до Forgejo / GlitchTip / CouchDB дела нет. Явное + # имя заодно активирует профиль `infra` само по себе, без оглядки на + # COMPOSE_PROFILES. Если конфигурация не менялась — compose ничего не + # пересоздаёт, шаг стоит доли секунды. + # + # СОЗДАВАТЬ кластер отсюда мы НЕ хотим — отсюда проверка на + # существующий контейнер. Первый старт — осознанный ручной шаг окна + # переезда (шаг 2 в docker-compose.prod.yml), и делается он с уже + # заполненными INFRA_PG_PASSWORD / FORGEJO_DB_PASS. Стартуй мы вслепую + # — пустой пароль дал бы отравленный том, который потом не + # переинициализировать. + if docker ps -a --format '{{.Names}}' | grep -qx 'gendesign-infra-postgres'; then + docker compose -p gendesign -f docker-compose.prod.yml up -d infra-postgres + echo "✓ infra-postgres приведён к конфигурации из main" + else + echo "⚠ контейнер gendesign-infra-postgres не найден — кластер ещё не поднят вручную, пропускаю" + fi + # НАМЕРЕННО НЕ ДЕЛАЕТСЯ: # - docker image prune: конкурирует с деплоем продукта за leases # докер-демона (#2950). Прун на этом хосте остаётся за diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e2722941..c8a3d0ef 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -337,6 +337,220 @@ services: # beat перестроит из `celery_app.conf.beat_schedule` на старте. command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"] + # ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ──────── + # Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.246.224.93), + # окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает целиком вместе с + # томом postgres_data (~15 ГБ) — а внутри него сегодня живут ЧЕТЫРЕ базы: + # gendesign 15 ГБ — Site Finder, уезжает + # auth 7.9 МБ — доступы «Меры»/«Птицы», уезжает с продуктом + # glitchtip 729 МБ — errors.gendsgn.ru, ОСТАЁТСЯ на Beget + # forgejo 265 МБ — git.gendsgn.ru + Actions CI, ОСТАЁТСЯ на Beget + # То есть в момент, когда postgres_data уедет, без базы останутся GlitchTip и + # Forgejo — а из Forgejo идёт сам деплой. Этот сервис — их новый дом. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ ЛЁГКИЙ КЛАСТЕР, А НЕ «оставить postgis на Beget». Держать + # на инфра-хосте postgis/postgis:16-3.4 с 15-гигабайтным томом ради ~1 ГБ + # реальных данных — это лишний диск, лишний RAM (у того сервиса ещё и + # shm_size: 1gb, #2812) и обновления PostGIS ради двух схем, которым геометрия + # не нужна вообще: forgejo (Go) и glitchtip (Django) — обычные реляционные + # схемы, ни geometry/geography, ни GIST по geom. Отсюда postgres:16-alpine — + # МАЖОРНАЯ ВЕРСИЯ ТА ЖЕ (16), значит дампы переносятся один-в-один, без + # апгрейда формата хранения. + # + # ПОРЯДОК ПЕРЕЕЗДА (все умолчания этого файла = сегодняшнее поведение, мерж и + # деплой сами по себе НИЧЕГО не переключают — сервис сидит в профиле `infra`, + # которого нет ни в одном .env): + # 1. дописать INFRA_PG_PASSWORD и FORGEJO_DB_PASS в /opt/gendesign/.env на + # Beget — сегодня их не читает никто, добавление безопасно. ОБА, а не + # один: initdb-скрипт падает на пустом любом из них, и падает уже ПОСЛЕ + # создания PGDATA, то есть повторно не выполнится никогда (см. мину + # initdb ниже и healthcheck, который такой полупустой кластер не пустит + # в healthy); + # 2. поднять пустой кластер: COMPOSE_PROFILES=glitchtip,infra в том же .env + # и `docker compose -p gendesign -f docker-compose.prod.yml up -d + # infra-postgres`. initdb заводит роли и ПУСТЫЕ базы. На прод это не + # влияет — в кластер ещё никто не ходит. Дождаться `healthy` в + # `docker ps`: до появления обеих баз healthcheck красный намеренно; + # 3. руками, в окно: pg_dump forgejo/glitchtip из gendesign-postgres-1 → + # psql ЦЕЛЕВОЙ РОЛЬЮ в gendesign-infra-postgres (почему именно ролью, а + # не суперюзером — в шапке ops/db-bootstrap/infra-postgres/01-*.sh). + # Этот шаг закрывает ops/split-infra-postgres.sh: гасит писателей, снимает + # дампы, заливает, сверяет. По умолчанию — СУХОЙ ПРОГОН, перенос только по + # --apply; прогнать сухой прогон стоит ЗАРАНЕЕ, засветло: он ловит забытый + # пароль в конфиге и расхождение версий до окна, а не в окне; + # 3a. СРАЗУ ЖЕ, тем же движением — прописать в конфиге бэкапа Forgejo + # (/etc/default/gendesign-backup-forgejo) `PG_CONTAINER=gendesign-postgres-1`. + # Не косметика: с момента заливки дампа непустая база forgejo лежит в + # ДВУХ запущенных контейнерах, а автоопределение в ops/backup-forgejo.sh + # на такую неоднозначность намеренно останавливается с exit 1 — то есть + # ночной бэкап git-хоста перестанет сниматься вовсе, и заметить это + # некому (канал оповещений check-backup-staleness.sh на этом хосте не + # настроен, см. ops/crontab-beget.cron). Пока боевой кластер старый — + # указываем старый; на шаге 4 меняем значение на gendesign-infra-postgres; + # после гашения старого кластера строку убираем совсем; + # 4. cutover: GLITCHTIP_DB_HOST=infra-postgres в /opt/gendesign/.env и + # `HOST = infra-postgres:5432` в app.ini Forgejo + # (/home/gendesign/forgejo/, его compose-файла в этом репозитории нет), + # затем перезапуск этих двух приложений и правка PG_CONTAINER из 3a. + # Откат = вернуть обе строки как было. Старый кластер к этому моменту не + # тронут: снятие дампов ничего в нём не меняет. + infra-postgres: + image: postgres:16-alpine + # Стабильное имя вместо compose-генерируемого `gendesign-infra-postgres-1`: + # ровно это имя перечислено в PG_CONTAINER_CANDIDATES у ops/backup-forgejo.sh + # (он ходит в БД через `docker exec "$PG_CONTAINER"` и выбирает контейнер по + # факту наличия данных, а не по зашитой константе — разбор в шапке того + # скрипта). Тот же приём уже применён у glitchtip-web, glitchtip-worker и + # gendesign-auth-forwarder. + container_name: gendesign-infra-postgres + logging: *default-logging + restart: unless-stopped + # РОВНО ОДИН профиль, и он НОВЫЙ — `infra`. Ни в одном /opt/gendesign/.env + # такого значения сегодня нет, поэтому мерж этой правки и любой следующий + # деплой для сервиса — честный no-op: compose его не видит, контейнер не + # создаётся, том не создаётся. + # + # ПОЧЕМУ НЕ `glitchtip`, хотя технически он подошёл бы. На Beget в + # /opt/gendesign/.env уже стоит COMPOSE_PROFILES=glitchtip (его выставляет + # scripts/bootstrap_glitchtip.sh), а docker-compose.prod.yml перечислен в + # paths: у .forgejo/workflows/deploy.yml, и шаг деплоя делает + # `set -a; source .env` и затем `docker compose -p gendesign up -d`. Общий + # профиль с glitchtip-web/worker означал бы, что кластер стартует на БОЕВОМ + # хосте в тот же час, когда PR влит, — то есть переключателем оказался бы + # сам мерж, что прямо противоречит требованию «все умолчания сохраняют + # сегодняшнее поведение». Хуже того, старт был бы СЛОМАННЫМ и МОЛЧАЛИВЫМ: + # INFRA_PG_PASSWORD в .env к тому моменту ещё нет, официальный энтрипойнт + # отказывается инициализировать кластер без пароля суперюзера, а + # `restart: unless-stopped` превращает это в бесконечный restart-loop. Деплой + # при этом зелёный — `up -d` не ждёт сервис, на который никто не depends_on, + # а health-check шага деплоя смотрит только на backend. + # Отдельный профиль переносит старт кластера туда, где ему и место: в шаг 2 + # порядка переезда, то есть в правку одной переменной на VM + # (COMPOSE_PROFILES=glitchtip,infra), выполняемую осознанно и с уже + # заполненными паролями. Откат — убрать `,infra`. + # + # ГАРАНТИЯ «GlitchTip не поднимется без своей БД» от этого не теряется: до + # cutover он ходит в общий кластер `postgres` и к infra-postgres отношения + # не имеет, а после cutover COMPOSE_PROFILES на остающемся хосте уже + # содержит infra — иначе шага 4 просто не было бы. + # + # Профиль назван по ХОСТУ, а не по приложению, потому что главный + # потребитель этого кластера — Forgejo, которого в данном compose нет вовсе + # (живёт отдельным стеком в /home/gendesign/forgejo, см. шапку + # ops/backup-forgejo.sh): привязать «БД для Forgejo» через depends_on не к + # чему. Если GlitchTip когда-нибудь выключат, COMPOSE_PROFILES=infra всё + # равно поднимет кластер — git.gendsgn.ru не должен зависеть от судьбы + # трекера ошибок. + # На Selectel профиль `infra` не активируется никогда (и GlitchTip, и Forgejo + # остаются на Beget) → сервис там не стартует и тома не создаёт. + profiles: ["infra"] + environment: + # Суперюзерская пара САМОГО кластера — это НЕ роли forgejo/glitchtip. + # Ею initdb-скрипт заводит прикладные роли и базы, ею же удобно ходить + # `docker exec`-ом в окно миграции и при разборе инцидентов. + # У пароля намеренно НЕТ дефолта-заглушки: с пустым INFRA_PG_PASSWORD + # официальный образ откажется инициализировать кластер с внятным + # "Database is uninitialized and superuser password is not specified", + # тогда как тихий `changeme` на хосте с публичным Forgejo — это дыра, + # которую никто не заметит. + POSTGRES_DB: ${INFRA_PG_DB:-infra} + POSTGRES_USER: ${INFRA_PG_USER:-infra} + POSTGRES_PASSWORD: ${INFRA_PG_PASSWORD} + # Читаются ТОЛЬКО initdb-скриптом (ops/db-bootstrap/infra-postgres/ + # 01-roles-and-databases.sh) — он создаёт ими роли-владельцы баз. + # GLITCHTIP_DB_PASS переиспользуется как есть, новая переменная не + # заводится: это тот же пароль, что уже стоит в DATABASE_URL у + # glitchtip-web/worker, и после cutover он ОБЯЗАН совпадать — иначе + # GlitchTip не залогинится в перенесённую базу. + FORGEJO_DB_PASS: ${FORGEJO_DB_PASS} + GLITCHTIP_DB_PASS: ${GLITCHTIP_DB_PASS} + volumes: + - infra_postgres_data:/var/lib/postgresql/data + # ⚠️ ТА ЖЕ МИНА initdb, что описана у сервиса postgres выше (#2989) и в + # tradein-mvp/docker-compose.prod.yml: каталог /docker-entrypoint-initdb.d + # исполняется ТОЛЬКО на ПУСТОМ томе и ТОЛЬКО один раз — на самом первом + # старте, до того как в кластер попадут любые данные. + # Здесь это ровно то, что нужно: сначала initdb создаёт роли и пустые базы, + # ПОТОМ руками заливаются дампы (роли обязаны существовать до + # восстановления — дампы сняты с --no-owner --clean --if-exists). + # Обратная сторона та же: если том infra_postgres_data уже не пуст, скрипты + # МОЛЧА не выполнятся — ни ошибки, ни строчки в логе, и правка скрипта + # после первого старта сама не применится. Прогнать заново = + # `docker volume rm gendesign_infra_postgres_data` (кластер пересоздастся + # с нуля) либо выполнить те же команды psql руками. + - ./ops/db-bootstrap/infra-postgres:/docker-entrypoint-initdb.d:ro + # Healthcheck проверяет НЕ ТОЛЬКО «кластер принимает соединения», но и что + # обе базы на месте — и это главное здесь, а не перестраховка. + # + # ЗАЧЕМ. Мина initdb выше имеет злую разновидность: ОТРАВЛЕННЫЙ ТОМ. Порядок + # шагов официального энтрипойнта — сначала initdb создаёт PGDATA (пишет + # PG_VERSION), и ТОЛЬКО ПОТОМ исполняется /docker-entrypoint-initdb.d. + # Значит любое падение bootstrap-скрипта (проще всего — забыть один из двух + # паролей в .env: FORGEJO_DB_PASS новый, а GLITCHTIP_DB_PASS там уже есть, + # так что «половина» выглядит совершенно правдоподобно) роняет контейнер + # УЖЕ ПОСЛЕ инициализации тома. На следующем рестарте энтрипойнт видит + # непустой PGDATA, объявляет DATABASE_ALREADY_EXISTS и пропускает + # initdb.d НАВСЕГДА — кластер поднимается, но ролей forgejo/glitchtip и их + # баз в нём нет и больше не появится. + # Один `pg_isready` такой кластер от готового не отличает: он зелёный, + # `docker ps` показывает healthy, и обнаружилось бы это в ночь переезда, на + # заливке дампа, репликой `role "forgejo" does not exist` — в окно, когда + # разбираться уже некогда. Проверка наличия обеих баз делает отравленный том + # видимым сразу и в самом обычном месте — в колонке STATUS у `docker ps`. + # Лечение то же: `docker volume rm gendesign_infra_postgres_data` и поднять + # заново с заполненными паролями. + # + # Идём в служебную базу `postgres`, а не в ${POSTGRES_DB}: pg_database + # виден из любой базы, а лишняя привязка к имени тут ни к чему. `-tA` даёт + # голое `t`/`f` без рамок и пробелов, `grep -qx t` не даст `f` пролезть как + # непустой вывод. Проверка остаётся зелёной и после переезда: базы никуда не + # деваются, а восстановление дампа с `--clean --if-exists` дропает объекты + # ВНУТРИ базы, но не саму базу. + healthcheck: + test: + - CMD-SHELL + - >- + pg_isready -q -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" && + psql -U "$${POSTGRES_USER}" -d postgres -tAc + "SELECT count(*) = 2 FROM pg_database WHERE datname IN ('forgejo', 'glitchtip')" + | grep -qx t + interval: 10s + timeout: 5s + retries: 10 + # 20s хватало на «кластер поднялся»; теперь в старт входит ещё и прогон + # initdb.d (создание двух баз из template0) — это доли секунды, но + # запас до 30s снимает мигание unhealthy на холодном старте. + start_period: 30s + # ~1 ГБ данных против 15 ГБ у основного кластера (glitchtip 729 МБ + forgejo + # 265 МБ). Считаем по-крупному: стоковый shared_buffers 128 МБ + ~25 + # соединений (пул Forgejo + web/celery GlitchTip) по ~8 МБ + запас на + # autovacuum и на восстановление дампа в окно миграции + # (maintenance_work_mem 64 МБ) ≈ 400 МБ пика. 512 МБ даёт над этим воздух и + # при этом на порядок меньше, чем съел бы оставленный на хосте postgis. + # Ниже (256 МБ) опускать не стоит — OOM-kill пришёлся бы ровно на заливку + # дампов, то есть на самый неудобный момент. + mem_limit: 512m + # shm_size НЕ поднимаем. У основного postgres 1 ГБ понадобился под DSM + # параллельных планов по таблицам Site Finder (#2812); ни Forgejo, ни + # GlitchTip на своих объёмах параллельных планов не строят — дефолтных + # 64 МБ хватает с запасом. + # + # Портов НЕТ и на хост ничего не пробрасывается — сознательно. У основного + # postgres есть `127.0.0.1:5432` ради SSH-туннеля к рабочей БД + # (`ssh -N gendesign` → localhost:15432), здесь такой нужды нет: обе базы + # обслуживаются своими приложениями по докер-сети, а разовая миграция и + # разбор инцидентов идут через `docker exec gendesign-infra-postgres psql`. + # Лишний слушающий сокет на хосте, где стоит ПУБЛИЧНЫЙ Forgejo, — чистый + # прирост поверхности атаки без единого выигрыша. + # + # Сеть ровно одна и именно default (`gendesign_default`): в ней сидит + # контейнер `forgejo` из отдельного стека /home/gendesign/forgejo, поэтому он + # достучится сюда по имени `infra-postgres`. `default` перечислен ЯВНО — как + # только у сервиса появляется блок networks:, неявная привязка к дефолтной + # сети пропадает (та же грабля описана у postgres и redis выше). `shared` не + # нужна: с trade-in этот кластер не разговаривает. + networks: [default] + glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web @@ -345,13 +559,42 @@ services: # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. profiles: ["glitchtip"] + # depends_on: БД здесь больше НЕТ, остался только redis (#3061). Зависимость + # обязана быть верной в ОБЕИХ фазах переезда, а она статична — имя сервиса + # нельзя вывести из GLITCHTIP_DB_HOST. Разобраны оба варианта: + # * оставить `postgres: service_healthy` — после cutover этот сервис на + # Beget не запускается вовсе (его том с Site Finder уехал), а depends_on + # на незапущенный сервис НЕ игнорируется: compose поднимет postgres + # вместе с glitchtip-web, то есть воскресит 15-гигабайтный postgis ровно + # там, откуда его убирали; + # * поставить `infra-postgres: service_healthy` — сделало бы доступность + # работающего сегодня GlitchTip заложником кластера, который до cutover + # ПУСТ и никем не используется: любая заминка с его паролями или + # healthcheck'ом роняла бы трекер ошибок. Это прямо нарушает требование + # «мерж и деплой ничего не меняют до переноса данных». + # Терять при этом нечего: `service_healthy` у postgres доказывал лишь то, что + # кластер принимает соединения, а не что база glitchtip и её миграции на + # месте. Холодный старт с ещё не готовой БД GlitchTip переживает — контейнер + # падает и поднимается заново по `restart: always` (ниже) с экспоненциальным + # backoff'ом и сходится за десятки секунд. redis оставлен: он в дефолтном + # профиле, присутствует в обеих фазах и нужен и брокеру celery, и веб-морде. depends_on: - postgres: - condition: service_healthy redis: condition: service_started environment: - DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + # GLITCHTIP_DB_HOST (#3061) — переключатель хоста БД на время переезда. + # Дефолт `postgres` = СЕГОДНЯШНЕЕ поведение байт-в-байт, поэтому мерж этой + # правки сам по себе ничего не меняет. Переключение на лёгкий кластер — + # одна строка `GLITCHTIP_DB_HOST=infra-postgres` в /opt/gendesign/.env на + # остающемся хосте, и только ПОСЛЕ того, как база glitchtip перенесена туда + # дампом. Откат — убрать строку и перезапустить оба glitchtip-контейнера. + # ⚠️ ПУСТОЕ значение переменной — НЕ то же самое, что «не задана»: + # `GLITCHTIP_DB_HOST=` даст DSN вида postgres://glitchtip:pass@:5432/..., + # то есть хост исчезнет из строки подключения и Django пойдёт в локальный + # сокет. Подстановка `:-` срабатывает только для НЕЗАДАННОЙ переменной. Та + # же грабля описана у CADDY_SITES в блоке caddy ниже: либо не задавать + # вовсе, либо задавать имя сервиса. + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} PORT: "8080" @@ -388,14 +631,20 @@ services: container_name: glitchtip-worker logging: *default-logging profiles: ["glitchtip"] + # depends_on без БД — по тем же причинам, что у glitchtip-web выше (там же + # разбор обоих отвергнутых вариантов). Для celery это ещё безопаснее: он и + # так обязан переживать обрыв соединения с базой в рантайме, а не только на + # старте. depends_on: - postgres: - condition: service_healthy redis: condition: service_started command: ./bin/run-celery-with-beat.sh environment: - DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + # Тот же переключатель GLITCHTIP_DB_HOST, что у glitchtip-web (полное + # обоснование и грабля с ПУСТЫМ значением — в комментарии там). Значение + # обязано совпадать с web: это один и тот же экземпляр базы, и разъехавшись, + # они дадут веб-морду и воркер, читающих разные кластеры. + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} CELERY_WORKER_AUTOSCALE: "1,3" @@ -493,6 +742,10 @@ services: volumes: postgres_data: + # Данные лёгкого кластера остающейся инфраструктуры (#3061). Отдельный том, а + # не соседство в postgres_data: тот уезжает на Selectel целиком, а этот обязан + # остаться на Beget — 30.08.2026 их жизненные циклы расходятся навсегда. + infra_postgres_data: redis_data: caddy_data: caddy_config: diff --git a/ops/backup-forgejo.sh b/ops/backup-forgejo.sh index 6c3d98cf..ec05661c 100755 --- a/ops/backup-forgejo.sh +++ b/ops/backup-forgejo.sh @@ -7,8 +7,31 @@ # - compose dir on the VM: /home/gendesign/forgejo/ # - repo data: /home/gendesign/forgejo/data/forgejo/git/repositories/ # - DB: NOT a dedicated container — a separate `forgejo` user+database -# inside the SAME shared postgres container as the main app -# (gendesign-postgres-1). `pg_dump -U forgejo forgejo` via `docker exec`. +# inside a SHARED postgres container. Снимается всегда одинаково, +# `pg_dump -U forgejo forgejo` через `docker exec`; меняется только то, +# В КАКОМ контейнере эта база лежит, и вот это уже не константа (см. ниже). +# +# ДВА ДОМА У БАЗЫ forgejo (#3061). Переезд продукта Beget → Selectel, окно +# 30.08.2026: контейнер `gendesign-postgres-1` уезжает целиком вместе с томом +# postgres_data, а Forgejo остаётся на Beget. Отсюда две фазы: +# ДО переезда — база forgejo живёт в общем кластере основного приложения, +# контейнер `gendesign-postgres-1`; +# ПОСЛЕ — в лёгком кластере остающейся инфраструктуры, контейнер +# `gendesign-infra-postgres` (сервис infra-postgres в +# docker-compose.prod.yml). +# +# ПОЧЕМУ ИМЯ КОНТЕЙНЕРА НЕ ЗАШИТО КОНСТАНТОЙ. Раньше здесь стояло +# `PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}"`. Беда в том, что после +# переноса старый контейнер не исчезает мгновенно — он ещё какое-то время жив и +# хранит СВОЮ копию базы forgejo, которая с момента переноса протухает с каждым +# коммитом и каждым PR. Константа означала бы, что бэкап продолжает исправно +# дампить именно её. И ни одна проверка ниже этого не поймала бы: дамп непустой, +# маркер "-- PostgreSQL database dump complete" на месте, размер заведомо выше +# MIN_DB_DUMP_BYTES — все пороги про ОБЪЁМ и ЦЕЛОСТНОСТЬ, ни один не про +# СВЕЖЕСТЬ. На выходе — здоровый с виду бэкап, который бэкапом не является, и +# узнаём мы об этом ровно в момент восстановления. Поэтому контейнер выбирается +# по ФАКТУ наличия ДАННЫХ среди запущенных (guard 2 ниже), а неоднозначность — +# база нашлась сразу в двух — это не повод угадать, а повод остановиться. # # Two artifacts per run: # 1. DB dump — `pg_dump --no-owner --clean --if-exists` (repos/issues/PRs/ @@ -68,7 +91,17 @@ FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}" FORGEJO_DATA_DIR="${FORGEJO_DATA_DIR:-${FORGEJO_DIR}/data/forgejo}" FORGEJO_REPOS_DIR="${FORGEJO_REPOS_DIR:-${FORGEJO_DATA_DIR}/git/repositories}" -PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}" +# Контейнер с базой Forgejo. ПО УМОЛЧАНИЮ ПУСТО — имени здесь больше нет, +# оно определяется автоматически в guard 2 (обоснование — в шапке). Явное +# значение из окружения или из ${FORGEJO_BACKUP_ENV_FILE} выигрывает и берётся +# как есть, без всяких проверок: это ручной override, и тот, кто его выставил, +# знает про свою инсталляцию больше, чем эвристика. +PG_CONTAINER="${PG_CONTAINER:-}" +# Где искать, когда PG_CONTAINER не задан. Список, а не одно имя: у базы forgejo +# два законных дома (#3061), и в окно переезда оба существуют одновременно. +# Переопределяется целиком — если имена контейнеров когда-нибудь снова +# поменяются, это правка одной строки в конфиге на VM, а не правка скрипта. +PG_CONTAINER_CANDIDATES="${PG_CONTAINER_CANDIDATES:-gendesign-postgres-1 gendesign-infra-postgres}" FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}" FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}" @@ -107,6 +140,88 @@ if [[ ! -d "$FORGEJO_REPOS_DIR" ]]; then exit 1 fi +# --- guard 2: в каком контейнере лежит ЖИВАЯ база forgejo (#3061) --- +# Полное обоснование «почему не константа» — в шапке файла. Здесь суть: имя +# контейнера меняется в момент переезда, а ошибка выбора не диагностируется по +# самому дампу — он выглядит здоровым и в старом, и в новом кластере. +# +# Критерий кандидата: контейнер ЗАПУЩЕН и в базе ${FORGEJO_DB_NAME} внутри него +# есть ТАБЛИЦЫ. Именно таблицы, а не сам факт существования базы — и это +# принципиально. Лёгкий кластер поднимается ПУСТЫМ на обычном деплое, за дни до +# ручного переноса данных, и его initdb-скрипт +# (ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh) сразу заводит роль +# forgejo и ПУСТУЮ базу forgejo. Проверяй мы «база существует» — в тот же вечер +# кандидатов стало бы двое, автоопределение упёрлось бы в неоднозначность и +# ночной бэкап покраснел бы ещё до того, как хоть один байт куда-то переехал. +# А это прямо нарушает главное требование переезда: мерж и деплой сами по себе +# не меняют ничего. С проверкой «есть таблицы» пустая заготовка кандидатом не +# считается, и неоднозначность возникает ровно тогда, когда она настоящая: +# дамп залит, данные есть в обоих кластерах, и решение действительно за +# человеком. +# +# Проверяем ровно тем же доступом, которым потом снимается дамп: psql под ролью +# ${FORGEJO_DB_USER} в базу ${FORGEJO_DB_NAME} через `docker exec` (внутри +# контейнера это unix-сокет, на нём в официальном образе стоит trust, пароль не +# нужен — на этом же держится и pg_dump ниже). Так проверка не расходится с +# делом: до чего не достучалась она, из того и pg_dump ничего не снимет. +if [[ -n "$PG_CONTAINER" ]]; then + log "PG_CONTAINER задан явно -> ${PG_CONTAINER}; автоопределение пропущено." +else + log "PG_CONTAINER не задан — ищу контейнер с непустой базой ${FORGEJO_DB_NAME} среди запущенных: ${PG_CONTAINER_CANDIDATES}" + pg_running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" + pg_found=() + # Разбиение по пробелам здесь НАМЕРЕННОЕ: PG_CONTAINER_CANDIDATES — это + # список имён, кавычки превратили бы его в одно имя с пробелами. + # shellcheck disable=SC2086 + for pg_candidate in $PG_CONTAINER_CANDIDATES; do + if ! grep -qxF -- "$pg_candidate" <<< "$pg_running"; then + log " ${pg_candidate}: не запущен — пропускаю" + continue + fi + # Пустой вывод = до базы не достучались вовсе (нет такой базы, нет роли, + # кластер не отвечает); `|| pg_tables=""` обязателен — под pipefail + # неудача docker exec иначе уронила бы весь скрипт прямо здесь, вместо + # внятного разбора кандидатов ниже. tr -dc отрезает перевод строки и + # любой мусор, чтобы (( )) получило заведомо число. + pg_tables="$(docker exec "$pg_candidate" psql -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" -tAc \ + "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')" \ + 2>/dev/null | tr -dc '0-9')" || pg_tables="" + if [[ -z "$pg_tables" ]]; then + log " ${pg_candidate}: запущен, но база ${FORGEJO_DB_NAME} под ролью ${FORGEJO_DB_USER} недоступна — пропускаю" + elif (( pg_tables == 0 )); then + log " ${pg_candidate}: запущен, база ${FORGEJO_DB_NAME} есть, но ПУСТА (0 таблиц) — заготовка под миграцию, пропускаю" + else + log " ${pg_candidate}: запущен, в базе ${FORGEJO_DB_NAME} таблиц: ${pg_tables} — кандидат" + pg_found+=( "$pg_candidate" ) + fi + done + + if (( ${#pg_found[@]} == 0 )); then + log "ERROR: не нашёл ни одного запущенного контейнера с НЕПУСТОЙ базой ${FORGEJO_DB_NAME}." >&2 + log "ERROR: искал среди: ${PG_CONTAINER_CANDIDATES}" >&2 + log "ERROR: критерий: контейнер в \`docker ps\` И \`psql -U ${FORGEJO_DB_USER} -d ${FORGEJO_DB_NAME}\` видит в нём хотя бы одну таблицу." >&2 + log "ERROR: строки выше показывают, на чём отсеялся каждый кандидат — 'не запущен' / 'недоступна' / 'ПУСТА'." >&2 + log "ERROR: сейчас запущены: $(echo "$pg_running" | tr -s '[:space:]' ' ')" >&2 + log "ERROR: что делать: если контейнер называется иначе — PG_CONTAINER=<имя> в ${FORGEJO_BACKUP_ENV_FILE};" >&2 + log "ERROR: если имён стало больше — PG_CONTAINER_CANDIDATES='<имя1> <имя2>' там же. Бэкап НЕ снят." >&2 + exit 1 + fi + + if (( ${#pg_found[@]} > 1 )); then + log "ERROR: непустая база ${FORGEJO_DB_NAME} нашлась сразу в нескольких запущенных контейнерах: ${pg_found[*]}" >&2 + log "ERROR: это ровно окно переезда (#3061): старый кластер ещё жив и держит УСТАРЕВШУЮ копию базы," >&2 + log "ERROR: новый — рабочую, и по дампу их не отличить (оба непустые, оба проходят пороги размера)." >&2 + log "ERROR: выбрать наугад значит с вероятностью 1/2 бэкапить труп, поэтому останавливаюсь." >&2 + log "ERROR: что делать: PG_CONTAINER=<боевой контейнер> в ${FORGEJO_BACKUP_ENV_FILE}," >&2 + log "ERROR: а после гашения старого кластера эту строку убрать — автоопределение снова однозначно. Бэкап НЕ снят." >&2 + exit 1 + fi + + PG_CONTAINER="${pg_found[0]}" + log "Автоопределение -> ${PG_CONTAINER}: единственный запущенный контейнер с непустой базой ${FORGEJO_DB_NAME}." +fi + mkdir -p "$LOCAL_BACKUP_DIR" ts=$(date -u +'%Y%m%d_%H%M%S') db_out="${LOCAL_BACKUP_DIR}/forgejo-db_${ts}.sql.gz" diff --git a/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh b/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh new file mode 100755 index 00000000..de30f0d3 --- /dev/null +++ b/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Роли и базы лёгкого инфраструктурного кластера `infra-postgres` (#3061). +# +# ЗАЧЕМ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает +# целиком вместе с томом postgres_data (~15 ГБ), а внутри него сегодня живут +# четыре базы — и две из них должны ОСТАТЬСЯ на Beget: +# forgejo 265 МБ — git.gendsgn.ru + Actions CI (из него же идёт деплой) +# glitchtip 729 МБ — errors.gendsgn.ru +# Этот файл готовит им новый дом: роли-владельцы и пустые базы в кластере +# postgres:16-alpine (сервис `infra-postgres` в docker-compose.prod.yml). +# +# КОГДА ИСПОЛНЯЕТСЯ — ровно один раз. Каталог /docker-entrypoint-initdb.d +# официальный образ читает ТОЛЬКО на пустом PGDATA и ТОЛЬКО до того, как в +# кластер попадут какие-либо данные. Это та же мина, что описана у сервиса +# postgres в docker-compose.prod.yml (#2989) и в tradein-mvp: если том +# infra_postgres_data уже инициализирован, скрипты МОЛЧА не выполнятся — ни +# ошибки, ни строчки в логе. Чтобы прогнать заново: снести том +# (`docker volume rm gendesign_infra_postgres_data`) либо выполнить те же +# команды руками через `docker exec -i gendesign-infra-postgres psql`. +# +# ПОРЯДОК ОТНОСИТЕЛЬНО ДАМПОВ — ГЛАВНОЕ. Дампы снимаются с +# `pg_dump --no-owner --clean --if-exists` (см. ops/backup-forgejo.sh): +# --no-owner → в дампе нет ни одной `ALTER ... OWNER TO`, владельцем +# восстановленных объектов становится РОЛЬ, ОТ ИМЕНИ +# КОТОРОЙ ИДЁТ ВОССТАНОВЛЕНИЕ; +# --clean --if-exists → дамп начинается с DROP'ов, поэтому его одинаково +# можно лить и в пустую базу, и поверх существующей. +# Отсюда два жёстких следствия для окна миграции: +# 1. роли forgejo/glitchtip обязаны существовать ДО восстановления — иначе +# psql упадёт на первом же `GRANT ... TO forgejo`. Их создаёт этот файл; +# 2. лить дамп нужно ИМЕННО целевой ролью, а не суперюзером кластера: +# docker exec -i gendesign-infra-postgres \ +# psql -v ON_ERROR_STOP=1 -U forgejo -d forgejo < forgejo.sql +# Восстановление суперюзером отдало бы ему все таблицы, и приложение +# получило бы "permission denied for table" на первой же записи. +# Если дамп содержит `CREATE EXTENSION` для НЕдоверенного расширения, владелец +# базы поставить его не сможет ("permission denied to create extension") — +# доверенные (pg_trgm, citext, btree_gin) в PG16 владельцу разрешены. Лечится +# разово: создать расширение суперюзером и повторить прогон дампа. +# +# ПАРОЛИ приходят из окружения контейнера (FORGEJO_DB_PASS / GLITCHTIP_DB_PASS +# в docker-compose.prod.yml ← /opt/gendesign/.env, chmod 600, вне git). В самом +# файле их нет и быть не может — только имена переменных. Не задан любой из +# двух → падаем громко: молча созданная беспарольная роль означала бы, что +# приложение не сможет залогиниться, и выяснилось бы это уже в окно миграции. +# +# ⚠️ У этого «падаем громко» есть цена, и её надо понимать. Порядок шагов +# энтрипойнта: PGDATA создаётся РАНЬШЕ, чем исполняется этот каталог. Значит наш +# exit 1 оставляет том ИНИЦИАЛИЗИРОВАННЫМ, но БЕЗ ролей и баз, а на следующем +# старте образ увидит непустой PG_VERSION и пропустит initdb.d НАВСЕГДА. Сам +# скрипт от этого защититься не может — он исполняется слишком поздно, — поэтому +# страховка вынесена в healthcheck сервиса infra-postgres: он требует наличия +# ОБЕИХ баз, и такой полупустой кластер никогда не станет healthy. Видно в +# `docker ps` в тот же день, а не в ночь переезда на заливке дампа. Лечение — то +# же, что описано выше: пересоздать том и поднять кластер заново с заполненными +# паролями. +# +# ⚠️ Файл ОБЯЗАН быть исполняемым (git mode 100755). Неисполняемые *.sh +# docker-entrypoint.sh не запускает, а ПОДКЛЮЧАЕТ через `.` — тогда `exit 1` из +# проверки паролей роняет сам энтрипойнт с невнятным кодом, а `set -u` протекает +# в его остаток. На Windows core.filemode=false, поэтому бит выставляется явно: +# `git update-index --chmod=+x ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh`. +# +# bash в postgres:16-alpine есть — на нём написан сам docker-entrypoint.sh +# образа, так что shebang безопасен и без установки пакетов. + +set -euo pipefail + +fail() { + echo "infra-postgres bootstrap: ОШИБКА: $*" >&2 + exit 1 +} + +[[ -n "${FORGEJO_DB_PASS:-}" ]] || fail \ + "FORGEJO_DB_PASS пуст. Задать в /opt/gendesign/.env ДО первого старта кластера — потом initdb.d уже не выполнится." +[[ -n "${GLITCHTIP_DB_PASS:-}" ]] || fail \ + "GLITCHTIP_DB_PASS пуст. Это тот же пароль, что стоит в DATABASE_URL у glitchtip-web/worker — он обязан совпасть, иначе после cutover GlitchTip не залогинится." + +# Подключаемся к служебной БД `postgres`, а не к ${POSTGRES_DB}: CREATE DATABASE +# нельзя выполнить, находясь в создаваемой базе, и лишняя привязка к имени +# служебной базы кластера тут ни к чему (подробный разбор — в +# ops/db-bootstrap/create_auth_db.sql). +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname postgres \ + -v forgejo_pw="$FORGEJO_DB_PASS" \ + -v glitchtip_pw="$GLITCHTIP_DB_PASS" <<'EOSQL' +-- Пароли кладём в сессионные GUC, а не подставляем прямо в текст DO-блока: +-- psql НЕ интерполирует :'var' внутри dollar-quoted блока ($$...$$) — это +-- правило psql, а не баг (инцидент деплоя 2026-05-24, разобран в +-- ops/db-bootstrap/set_tradein_fdw_password.sql). set_config вызывается ВНЕ $$, +-- значит подстановка срабатывает, а внутрь блока значение приходит через +-- current_setting(); format(%L) экранирует его как SQL-литерал, поэтому пароль +-- с кавычками безопасен. +-- +-- \o /dev/null вокруг set_config: функция ВОЗВРАЩАЕТ установленное значение — +-- без глушения psql напечатал бы пароль в stdout, то есть в лог контейнера +-- (`docker logs gendesign-infra-postgres` и journald, #2761). +\o /dev/null +SELECT set_config('app.forgejo_pw', :'forgejo_pw', false); +SELECT set_config('app.glitchtip_pw', :'glitchtip_pw', false); +\o + +-- Роли. Идемпотентно: на пустом кластере это CREATE, при ручном повторном +-- прогоне — ALTER, синхронизирующий пароль с текущим окружением (важно, если +-- пароль в /opt/gendesign/.env поменяли, а том пересоздавать не хочется). +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'forgejo') THEN + EXECUTE format('CREATE ROLE forgejo LOGIN PASSWORD %L', current_setting('app.forgejo_pw')); + RAISE NOTICE 'роль forgejo создана'; + ELSE + EXECUTE format('ALTER ROLE forgejo WITH LOGIN PASSWORD %L', current_setting('app.forgejo_pw')); + RAISE NOTICE 'роль forgejo уже существовала — пароль синхронизирован с окружением'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip') THEN + EXECUTE format('CREATE ROLE glitchtip LOGIN PASSWORD %L', current_setting('app.glitchtip_pw')); + RAISE NOTICE 'роль glitchtip создана'; + ELSE + EXECUTE format('ALTER ROLE glitchtip WITH LOGIN PASSWORD %L', current_setting('app.glitchtip_pw')); + RAISE NOTICE 'роль glitchtip уже существовала — пароль синхронизирован с окружением'; + END IF; +END $$; + +-- Базы. CREATE DATABASE нельзя ни внутри DO-блока (это функция, она идёт в +-- транзакции), ни вообще в транзакционном блоке — поэтому идемпотентность +-- делается через \gexec: команда собирается на стороне клиента, и если +-- WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и молча +-- ничего не делает. ON_ERROR_STOP=1 распространяется и на \gexec. +-- +-- TEMPLATE template0 — сознательно, а не template1: template0 гарантированно +-- пуст и неизменяем, тогда как в template1 кто угодно мог доустановить объекты +-- или расширения, и они молча оказались бы внутри баз Forgejo/GlitchTip. +-- ENCODING 'UTF8' задан явно, чтобы кириллица в issue и комментариях Forgejo и +-- в заголовках событий GlitchTip не зависела от того, с какими аргументами +-- когда-нибудь пересоздадут кластер. +-- +-- OWNER — прикладная роль, а НЕ суперюзер (в отличие от базы `auth`, где +-- владельцем сознательно оставлен суперюзер): дампы сняты с --no-owner и +-- восстанавливаются от имени самой роли, значит она обязана иметь право +-- создавать объекты в своей базе. +SELECT 'CREATE DATABASE forgejo OWNER forgejo TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'forgejo') +\gexec + +SELECT 'CREATE DATABASE glitchtip OWNER glitchtip TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'glitchtip') +\gexec + +-- По умолчанию PostgreSQL выдаёт CONNECT на новую БД роли PUBLIC — то есть +-- glitchtip мог бы открыть сессию в базе Forgejo и наоборот. В общем кластере +-- на публично доступном хосте это лишнее. Владельцу REVOKE ничего не отнимает: +-- права владельца объекта не берутся из ACL. Команды идемпотентны. +REVOKE ALL ON DATABASE forgejo FROM PUBLIC; +REVOKE ALL ON DATABASE glitchtip FROM PUBLIC; + +COMMENT ON DATABASE forgejo IS + 'Forgejo (git.gendsgn.ru + Actions CI). Перенесена из gendesign-postgres-1 при ' + 'переезде продукта на Selectel (#3061). Бэкап — ops/backup-forgejo.sh.'; +COMMENT ON DATABASE glitchtip IS + 'GlitchTip (errors.gendsgn.ru). Перенесена из gendesign-postgres-1 при переезде ' + 'продукта на Selectel (#3061).'; + +-- Чистим GUC после использования — defense-in-depth, чтобы пароль не оставался +-- в состоянии сессии даже на короткое время. Тот же приём с \o: set_config +-- возвращает пустую строку, но лишний ряд в stdout не нужен. +\o /dev/null +SELECT set_config('app.forgejo_pw', '', false); +SELECT set_config('app.glitchtip_pw', '', false); +\o +EOSQL + +echo "infra-postgres bootstrap: роли forgejo/glitchtip и их базы готовы; можно восстанавливать дампы целевыми ролями." diff --git a/ops/gendesign-backup-forgejo.default.example b/ops/gendesign-backup-forgejo.default.example index 945e220b..78e9d4e5 100644 --- a/ops/gendesign-backup-forgejo.default.example +++ b/ops/gendesign-backup-forgejo.default.example @@ -39,6 +39,42 @@ #FORGEJO_DIR=/home/gendesign/forgejo # Forgejo compose dir on the VM (NOT # part of the gendesign git checkout) #FORGEJO_REPOS_DIR=/home/gendesign/forgejo/data/forgejo/git/repositories -#PG_CONTAINER=gendesign-postgres-1 # Forgejo's DB lives in the SAME shared - # postgres container as the main app, - # as a separate `forgejo` user+database + +# --- контейнер с БД Forgejo (#3061) --- +# ЗАДАВАТЬ НЕ НУЖНО почти никогда: ops/backup-forgejo.sh находит контейнер сам. +# Он перебирает PG_CONTAINER_CANDIDATES, оставляет запущенные и берёт тот, в +# чьей базе `forgejo` реально ЕСТЬ ТАБЛИЦЫ. +# +# ПОЧЕМУ НЕ КОНСТАНТА. Раньше имя было зашито (gendesign-postgres-1 — общий +# кластер основного приложения). Переезд продукта на Selectel переносит базу +# forgejo оттуда в лёгкий инфраструктурный кластер (gendesign-infra-postgres, +# сервис infra-postgres в docker-compose.prod.yml), а старый контейнер после +# переноса какое-то время ещё жив и держит свою — с этого момента протухающую — +# копию базы. Зашитое имя означало бы, что бэкап продолжает исправно дампить +# именно её: дамп непустой, маркер завершения на месте, размер выше порога — и +# ни одной новой строки с момента cutover. Порогов на СВЕЖЕСТЬ здесь нет, так +# что выяснилось бы это только при восстановлении. +# +# КОГДА ЗАДАВАТЬ ЯВНО — ровно один случай: окно переезда, когда непустая база +# forgejo есть СРАЗУ В ДВУХ запущенных контейнерах. Автоопределение тогда не +# гадает, а останавливается с ошибкой (выбрать наугад = 50% шанс забэкапить +# труп) и ждёт прямого указания, какой контейнер боевой. Момент, с которого это +# нужно, наступает НЕ на cutover'е, а РАНЬШЕ — с первой же заливки дампа в новый +# кластер (шаг 3a в комментарии к сервису infra-postgres, +# docker-compose.prod.yml): с этой секунды кандидатов двое, и ночной бэкап +# git-хоста иначе просто не снимется. Поэтому значение здесь живёт двумя фазами: +# до cutover'а — боевой ещё старый кластер: +#PG_CONTAINER=gendesign-postgres-1 +# после — новый: +#PG_CONTAINER=gendesign-infra-postgres +# Строку УБРАТЬ, как только старый контейнер погашен: дальше автоопределение +# снова однозначно и переживёт любое следующее переименование само. +# +# Явное значение берётся КАК ЕСТЬ, без проверок — это ручной override, и тот, +# кто его выставил, знает про свою инсталляцию больше. Пустое значение +# (PG_CONTAINER=) равносильно незаданному: включается автоопределение. + +# Где искать, когда PG_CONTAINER пуст. Имена через пробел; КАВЫЧКИ ОБЯЗАТЕЛЬНЫ — +# файл читается через `source`, и без них второе имя bash попытается выполнить +# как команду. Трогать только если контейнеры называются иначе, чем здесь. +#PG_CONTAINER_CANDIDATES="gendesign-postgres-1 gendesign-infra-postgres" diff --git a/ops/split-infra-postgres.sh b/ops/split-infra-postgres.sh new file mode 100755 index 00000000..7ca0f8c3 --- /dev/null +++ b/ops/split-infra-postgres.sh @@ -0,0 +1,547 @@ +#!/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 <контейнер> <база/роль> + 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 <