paths-filter в deploy.yml знал только про ops/docker-prune.sh (#2887) — правка
ops/backup.sh или новый ops/restore-drill.sh из этого же PR не долетели бы до
/opt/gendesign: деплой не триггерится -> git reset --hard origin/main не
исполняется -> cron на VM месяцами крутит старую версию, молча.
Точечный список сам по себе и есть баг: #2887 добавил только тот файл, о
котором тогда шла речь, и следующий новый ops-скрипт (backup.sh) остался
за бортом. Глоб ops/*.sh закрывает класс целиком — не матчит подпути
(ops/db-bootstrap/**, ops/glitchtip-auth-forwarder/**), у них свои explicit
триггеры уже есть, дублирования нет.
Слить актуальный main (билд-раннер #2841/#2869, невалидные индексы #2752,
честный health-check и deploy-status #2841) в ветку очистки CI. Один конфликт
в .forgejo/workflows/deploy.yml: список triggers.paths — main добавил
ops/docker-prune.sh (#2887), ветка добавила auth/** (RBAC roles config).
Разрешено сохранением обоих путей, без потери ни одного триггера.
Ревью R2 нашёл, что вся безопасность предыдущего фикса держалась на
недоказанной поддержке act_runner'ом steps.<id>.outcome: если раннер его
не заполняет, retry-шаг молча не бежит, continue-on-error проглатывает
падение сборки, job зелёный — а деплой тянет старый :latest на прод.
- Добавлен engine-agnostic verify-шаг после каждого retry (6 мест,
deploy.yml + deploy-tradein.yml): `docker buildx imagetools inspect
<image>:<sha>` без continue-on-error. Не зависит от того, поддерживает
ли раннер outcome — проверяет реальное состояние registry напрямую.
Если ни build, ни retry реально не запушили образ — шаг падает и job
честно FAILURE независимо от семантики outcome.
- Вернул `cache-to` в retry-шаги (6 мест): без него битый buildcache-тег
никогда не перезаписывался — retry всегда собирал без cache-to, значит
cache-to не выполнялся НИКОГДА, и каждый следующий прогон снова падал
на том же cache-from. Заявленное самолечение не работало ни разу.
- Health-check в deploy.yml (main-стек) под `set -e` не мог упасть:
`curl ... && break` — curl не последняя команда &&-списка, POSIX
освобождает такие команды от errexit, цикл дохаживал до sleep (exit 0)
даже если curl ни разу не отдал 200. Приведено к паттерну
deploy-tradein.yml: явный флаг healthy + `exit 1` после цикла.
Подтверждено локальным bash-репро (mock curl, всегда failure): старая
версия — exit 0, новая — exit 1; позитивный сценарий не сломан.
docker rm -f без -v в SSH-скриптах деплоя не тронут.
Зелёная галка прогона не отличима от пропущенного деплоя: если build падает
из-за битого blob в удалённом buildcache, шаг deploy молча пропускается
(if-условие даёт result=skipped), а прогон в целом не подсвечен как FAILED.
- deploy-status: новая job в конце deploy.yml и deploy-tradein.yml, всегда
бежит (if: always() && !cancelled()) и падает явно, если deploy.result !=
success — неважно, пропущен он (upstream build/test упал) или упал сам.
- cache-from нефатален: каждый build-push-action-шаг получил id + continue-
on-error, и ретрай без cache-from/cache-to при steps.build.outcome ==
'failure'. Битый remote-кеш больше не роняет саму сборку; следующий
успешный прогон с кешем перезаписывает buildcache-тег целиком (mode=max)
и самолечит порчу. Реальные ошибки сборки (не кеш) по-прежнему валят job
на ретрае — deploy-status их тоже поймает.
Гейт против публикации services-портов на VPS (та же задача, проблема 1)
уже покрыт scripts/check-workflow-ports.py + шагом в ci.yml (#2757/#2759,
слит ранее) — сканирует все .forgejo/workflows/*.yml, включая эти два файла;
новых правок не потребовалось.
docker rm -f БЕЗ -v в SSH-скриптах деплоя не тронут — эти вызовы намеренно
без -v (боевые тома), правка их не касается.
Скрипт уборки docker-мусора (#2887) исполняется на прод-VM по cron из
/opt/gendesign/ops/. Файлы туда попадают единственным путём — шагом
`git reset --hard origin/main` внутри deploy.yml.
Но paths-фильтр deploy.yml перечисляет подпути ops/ поимённо, а не ops/**.
Поэтому мерж #2887 деплой НЕ запустил: скрипт остался в main, на VM его не
было, а установленный cron указывал в пустоту. Правки скрипта и дальше
доезжали бы только случайно — со следующим чужим коммитом в backend/.
Ровно этот же баг уже ловили на ops/db-bootstrap/** — там рядом стоит
комментарий с той же формулировкой. Добавляю ops/docker-prune.sh по образцу
и фиксирую грабли в rules/deploy.md, чтобы следующий исполняемый файл в ops/
не наступил на них третий раз.
- deploy.yml: добавлен auth/** в path-триггеры. app.core.auth кэширует
roles.yaml на весь lifetime процесса (@lru_cache), а файл монтируется
ТОЛЬКО в backend (не в worker, у него лишь ./data и ./reports) — без
триггера правка ролей вступала бы в силу в случайный момент.
- ci-tradein.yml: добавлен блокирующий шаг `ruff check` — гейт для tradein
backend его не гонял вообще. Заодно почищены 3 срабатывания RUF059
(unused unpacked vars) в test_estimator_pure_units.py:381 по established
convention (`_` уже используется в соседнем тесте того же файла).
- Удалён мёртвый .github/workflows/ci.yml (Forgejo его не исполняет; GitHub
mirror отстал на 500+ коммитов). Закомментированный postgres-services блок
НЕ перенесён — обоснование, ради которого его хотели оставить как заготовку
("тестов против живой БД нет"), уже неверно: tests/sql/ реально гоняются
против Postgres в ci.yml (#2745), и там намеренно НЕ используется `services:`
— раннер поднимает job и service-контейнеры в сети хоста, где порт 5432
занят прод-базой (см. ci.yml:91-100).
- README.md: убрана ложная привязка mypy к живому CI-гейту (только ruff +
pytest + coverage gate ≥65%); убран stale-блок про .github/workflows/ —
каталог после удаления ci.yml пуст.
PR-1 эпика: вся авторизация переезжает на одну нейтральную форму входа, браузерный
popup (Caddy basic_auth) убирается. Этот PR — ТОЛЬКО фундамент, прод работает как
сейчас: в БД gendesign ничего не меняется, новая БД создаётся и наполняется
логинами без паролей, читать её пока некому.
Почему отдельная БД, а не таблица в существующей: хранилище доступов не должно
принадлежать продукту, из которого аккаунты выносятся. Сервер — существующий
gendesign-postgres (новый контейнер не заводим); проверено, что оба бэкенда
сидят в сети gendesign_shared и TCP-достают до него.
Состав:
- data/sql/auth/001-003 — схема (users, sessions), роль приложения, сид 13 логинов.
password_hash = NULL у ВСЕХ: plaintext и bcrypt-хеши в git запрещены, пароли
проставляются отдельно на проде (конвенция репы, прецедент tradein м.193).
- ops/db-bootstrap/create_auth_db.sql — CREATE DATABASE через \gexec. Не миграцией:
CREATE DATABASE запрещён в транзакции, а миграции обязаны быть транзакционными.
- ops/db-bootstrap/set_auth_app_password.sql — пароль роли из env, зеркало
set_tradein_fdw_password.sql (GUC + \o /dev/null + %L, строго через stdin —
:'pw' не интерполируется внутри $$...$$, на этом падал деплой 2026-05-24).
Схема лежит в ПОДКАТАЛОГЕ data/sql/auth/ намеренно: основной цикл деплоя использует
`ls -1 data/sql/*.sql`, который в подкаталоги не рекурсирует → эти файлы физически
не могут примениться в БД gendesign. Защита не на дисциплине, а на глобе. Триггер
`data/sql/**` подкаталог при этом покрывает.
Права: владелец БД — суперюзер, а не auth_app (иначе гранты были бы декорацией).
users — только SELECT+UPDATE (INSERT не выдан: создания аккаунтов в этом PR нет, а
снять грант, на который уже опирается прод-код, сложнее чем выдать). REVOKE ALL ON
DATABASE FROM PUBLIC продублирован в bootstrap и в миграции намеренно: bootstrap
гоняется каждый деплой (переприменяемость), миграция — однократно (самодостаточность).
Проверено ИСПОЛНЕНИЕМ на postgis/postgis:16-3.4 (тот же образ, что на проде):
двойной прогон всех файлов идемпотентен; COALESCE-защита сида не затирает вручную
проставленные пароль/имя (проверено живьём); ASCII-CHECK отклоняет кириллицу;
auth_app коннектится, посторонняя роль → permission denied; битая миграция даёт
exit 1 и НЕ пишется в _schema_migrations, т.е. деплой прервётся до подъёма кода;
пароль с кавычками и бэкслешем не ломает %L и не печатается в stdout.
Тест backend/tests/sql/test_auth_sql_migrations.py: имена, транзакционность, наличие
wiring в deploy.yml и детектор паролей/хешей, покрывающий И data/sql/auth, И
ops/db-bootstrap — единственное место в репе с ALTER ROLE ... PASSWORD.
Детектор проверен на живучесть: подложенный bcrypt-хеш роняет тест.
Открытые развилки зафиксированы комментариями в коде, решаются в PR-2/3:
судьба tradein_users/tradein_sessions (два одинаковых по схеме хранилища) и
expired != disabled (trial-экран не выражается булевым is_active).
NB: переменную AUTH_DB_PASSWORD нужно завести вручную в runtime-env бэкенда на VPS.
Пока пусто — шаг ALTER ROLE пропускается с warning'ом, деплой не падает.
Mirror the LLM_ENABLED wiring for OWN_DEVELOPER_IDS (own-portfolio
cannibalization, #1169): forward vars.OWN_DEVELOPER_IDS into the SSH deploy
(env block + envs list) and write it to backend/.env.runtime ONLY when
non-empty (conditional, like OPENAI_API_KEY/LLM_ENABLED). Non-sensitive
(public companyGroup ids) → actions variable. Unset → cannibalization keeps
the proxy (dormant). No literal id in the workflow (refs only).
Captures in the pipeline the value already set live on prod via .env.runtime
(OWN_DEVELOPER_IDS=6208 = PRINZIP, verified against domrf_kn → 28 projects).
Owner must set Forgejo Variable OWN_DEVELOPER_IDS=6208 for full pipeline
management; meanwhile the .env.runtime line holds (survives git reset).
Refs #1169
Forward OPENAI_API_KEY (Forgejo secret) and LLM_ENABLED (Forgejo actions
variable) into the SSH deploy and write them to backend/.env.runtime ONLY
when non-empty, so the LLM chat (#960/#957) stays dormant
(llm_enabled=False, openai_api_key=None) until BOTH are set in Forgejo.
Mirrors the OBJECTIVE_API_KEY wiring (env block + envs list + idempotent
.env.runtime grep-sed-or-printf), but guarded by [ -n ] so an unset
secret/var writes nothing. No literal key (refs only). Compose header
documents both as optional. .env.runtime is shared by backend+worker+beat.
Refs #960
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
Раньше deploy.yml делал `docker compose exec caddy caddy reload` — это
только reload Caddyfile, но НЕ пересоздаёт container. Когда в
docker-compose.prod.yml добавлялся новый volume mount (например
./preview:/srv/preview:ro в PR #268), он не появлялся в running container
до manual `docker compose up -d --force-recreate caddy` через SSH.
Теперь deploy всегда вызывает force-recreate caddy после image pull —
идемпотентно, +~5 сек к deploy time, гарантирует что новые mounts +
Caddyfile блоки активны.
Problem
- GlitchTip Issues = 0 за всё время, хотя backend + frontend SDK интегрированы (PR #207, #208).
- Старый Sentry.io продолжает получать события — user видит уведомления оттуда.
Root cause
- frontend/Dockerfile не имеет ARG NEXT_PUBLIC_GLITCHTIP_DSN → `npm run build` бьёт
с пустым env var → Next.js инлайнит undefined → SDK init guard `if (dsn)` skips.
Chrome-devtools check на prod bundle подтвердил: ни в одном из 9 chunks DSN-строка
не запечена; `i.env.NEXT_PUBLIC_GLITCHTIP_DSN` evaluates to undefined.
- .forgejo/workflows/deploy.yml build-frontend не передавал build-args.
- На VPS backend/.env.runtime содержит legacy SENTRY_DSN=...@sentry.io/...
config.py:_promote_legacy_sentry_dsn слепо промоутит его в glitchtip_dsn → SDK
шлёт в чужой Sentry. GLITCHTIP_DSN там не задан.
- deploy.yml SSH-скрипт никогда не редактировал SENTRY_DSN/GLITCHTIP_DSN в .env.runtime.
Solution
1. frontend/Dockerfile: ARG NEXT_PUBLIC_GLITCHTIP_DSN + NEXT_PUBLIC_ENVIRONMENT
с пустыми defaults, ENV-mirror перед `npm run build`. Локальный build без
build-args работает по-прежнему (no-op DSN).
2. .forgejo/workflows/deploy.yml:
- build-frontend: `build-args` передаёт `secrets.GLITCHTIP_FRONTEND_DSN`
+ NEXT_PUBLIC_ENVIRONMENT=production. Инвалидирует cache → frontend
image пересоберётся (expected).
- deploy step: GLITCHTIP_BACKEND_DSN через secret, в SSH-скрипте:
a) `sed -i '/^SENTRY_DSN=/d' backend/.env.runtime` — снести legacy
b) upsert GLITCHTIP_DSN (sed/printf) тем же паттерном что SENTRY_RELEASE
c) `compose up -d --force-recreate --no-deps backend worker beat` —
обычный `up -d` не перечитывает env_file без image change.
3. backend/app/core/config.py: _promote_legacy_sentry_dsn ужесточён —
принимает SENTRY_DSN только если host == errors.gendsgn.ru. Для других
URLs (sentry.io) выдаёт UserWarning и НЕ промоутит. Anti-regression на
случай если SENTRY_DSN снова окажется в .env.runtime после ручного
вмешательства.
Required Forgejo secrets (Settings → Actions → Secrets)
- GLITCHTIP_BACKEND_DSN = https://3d6e291003e142458957490c83559867@errors.gendsgn.ru/1
- GLITCHTIP_FRONTEND_DSN = https://5d7bc85e300c4e80a8554ccc818ff56d@errors.gendsgn.ru/2
DSNs публичны (видны в browser bundle) — secrets ради build-time injection,
не для конфиденциальности. Если secrets не заданы → deploy succeeds, SDK
no-op, без регрессии.
Test plan
- Verify Forgejo deploy.yml зелёный после merge
- chrome-devtools: открыть gendsgn.ru → search bundle на DSN string → должна
быть запечена строка errors.gendsgn.ru/2
- Trigger frontend error → POST к errors.gendsgn.ru/api/2/envelope/
- Backend: curl на endpoint вызывающий 500 → событие в GlitchTip backend project
- GlitchTip dashboard https://errors.gendsgn.ru/gendesign/issues — Issues > 0
References
- vault: meta/00_credentials.md (DSNs + incident notes 2026-05-16)
- vault: decisions/Dec_GlitchTip_Frontend_Sentry_SDK.md (env contract)
- vault: fixes/fixes-MOC.md (#204 backend SDK init)