#!/usr/bin/env bash # Роли и базы лёгкого инфраструктурного кластера `infra-postgres` (#3061). # # ЗАЧЕМ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare # (188.124.37.140), окно 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 и их базы готовы; можно восстанавливать дампы целевыми ролями."