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