aws-cli v2 (образ amazon/aws-cli:latest) использует собственный набор
корневых сертификатов из botocore, а не системное хранилище контейнера.
В нём нет корня, которым подписан сертификат Selectel S3
(s3.ru-1.storage.selcloud.ru), поэтому docker run падал на
SSL validation failed / CERTIFICATE_VERIFY_FAILED: self-signed
certificate in certificate chain — и ночной S3-upload не проходил
каждый раз, хотя ключи и bucket были верны.
Проверено на проде: без AWS_CA_BUNDLE — SSL validation failed; с
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt (системное хранилище
контейнера, содержит нужный GlobalSign-корень) — upload 11 МБ проходит,
код возврата 0, объект подтверждён чтением вторым ключом.
Правка — одна переменная окружения в docker run в обоих скриптах:
- ops/backup.sh (main DB backup)
- tradein-mvp/deploy/backup-tradein-db.sh (tradein DB backup, #3004)
core.filemode=false на Windows-чекауте молча создал файл как 100644;
остальные ops/*.sh — 100755, restore-drill.sh должен быть исполняемым
так же (cron/deploy конвенция ops/backup.sh).
- ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh теперь дампят
globals (pg_dumpall --globals-only) отдельным файлом с той же ретенцией
и той же S3-выгрузкой — pg_dump по определению не включает роли/GRANT.
- Обе выгрузки проходят gzip -t + проверку трейлера дампа перед тем как
считаться успешными; при провале файл удаляется, ретенция не трогается,
выход ненулевой.
- Убран 2>/dev/null у pg_dump в обоих скриптах — ошибка дампа теперь
видна в логе, а не глотается молча.
- tradein-backup.sh получил S3-выгрузку (по образцу ops/backup.sh, те же
4 переменные, тот же способ через aws-cli контейнер) и env-переопределяемый
порог минимального размера дампа; источник переменных —
/etc/default/tradein-backup с фолбэком на /etc/default/gendesign-backup.
- Новый ops/restore-drill.sh — учебное восстановление в одноразовый
postgis-контейнер без прод-томов, никогда не трогает боевую БД (в отличие
от ops/restore.sh, который восстанавливает В БОЕВУЮ базу).
Три независимые правки инфраструктурной гигиены прод-VPS, замеренные живьём
2026-08-15 (ssh gendesign, read-only).
1. ops/docker-prune.sh: новая секция снимает зависшие (running, но брошенные)
job-контейнеры Forgejo Actions раннера — три штуки Up 4-8 недель на проде,
docker container prune их не видит (фильтрует только status=exited).
Фильтр по имени — якорь ^FORGEJO-ACTIONS-TASK- (regex, не substring),
сервисные контейнеры (forgejo/forgejo-runner*/gendesign-*/tradein-*/couchdb)
под него не подпадают + explicit-skip как страховка. Возраст — из
docker inspect .State.StartedAt, порог JOB_CONTAINER_MAX_AGE_HOURS (default
24 — CI job столько никогда не идёт). DRY_RUN уже существующий флаг,
переиспользован. Идемпотентно на пустом списке (mapfile + `|| true`).
2. ops/journald-gendesign.conf.example: SystemMaxUse=500M для journald.
Замер: journalctl --disk-usage без прав группы systemd-journal/adm
недосчитывает (показал 174M) — реальный du -sh /var/log/journal = 2.5G,
~80% всех 3.1G /var/log. journald.conf на проде сейчас без лимита вообще.
Установка — руками на сервере (systemd-конфиг вне /opt/gendesign, deploy.yml
его не синкает): инструкция в шапке файла.
3. Caddyfile: убран мёртвый блок status.gendsgn.ru (указывал на
несуществующий uptime-kuma, дёргал ACME раз в 6 часов на staging-эндпоинте).
Заодно убрана dangling forward-ссылка "описан для status.gendsgn.ru ниже" в
комментарии у meraocenka.ru. grep подтвердил: других ссылок на этот хост в
Caddyfile/compose/скриптах не осталось (docker-compose.uptime.yml и README
упоминают — вне scope, стек сам по себе не трогается). caddy validate через
prod-контейнер (stdin, без записи файлов на диск) — конфиг валиден, как и
baseline до правки.
83% of tracker issues (7460 total) were pure noise drowning real signal:
- basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth-
forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/
install.php etc.) as an individual GlitchTip event, remote_ip baked into
message/tags inflated cardinality. Not an application error — expected
bot-scan traffic against a basic_auth-protected site.
- RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped
Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on
exhaustion without reraise=True; RetryError.__str__() embeds a Future
repr() with a memory address that differs every call, so GlitchTip
grouped each exhausted retry as a distinct issue instead of one.
Fix at the source, not post-hoc issue cleanup:
- forwarder.py: before_send drops events tagged event_type in
{basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception
(real script bugs) carries no such tag and passes through untouched.
- geocoder.py: reraise=True on all three @retry decorators — propagates
the real underlying exception (stable type + stacktrace) instead of the
unstable RetryError wrapper.
- sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders
before_send hook, composed into both app/main.py and scheduler_main.py
(geocoder runs in both processes — FastAPI request path and the
overnight geocode_missing_listings batch). Collapses any RetryError that
still slips through into one persistent issue per cause-exception type
name only — never IP/address/listing-id.
Content-ful categories (OperationalError, city-sweep, harvest_quarter,
cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues)
are untouched: filters key off event_type tag / exception type name only.
Оба скрипта закоммичены как 100644, хотя соседние по каталогу backup.sh и
uptime-healthcheck.sh — 100755. На прод-VM файлы лежат с +x, поэтому
`git status` в /opt/gendesign постоянно показывает их как modified:
M ops/docker-prune.sh
M ops/restore.sh (old mode 100644 / new mode 100755, контент идентичен)
Функционально это ничего не ломает: cron зовёт docker-prune.sh через
`bash <путь>`, restore.sh запускают руками так же. Проблема в другом —
постоянный M в прод-репозитории обесценивает единственный дешёвый сигнал,
по которому видно ручную правку файла на проде. Шум надо убирать, а не
привыкать к нему.
Приводим режим к тому, что реально на диске и что уже стоит у соседей.
Диск был занят на 76% (110 из 145 ГБ). Разбор: 201 том-сирота на 12.6 ГБ —
125 анонимных (каталоги данных PostgreSQL от тестовых прогонов CI) и 76
окружений задач Forgejo Actions. Прод-данных среди них нет ни одного.
Корневая причина: ci.yml и ci-tradein.yml поднимают свой postgres и снимают
его через `docker rm -f` БЕЗ `-v`. Контейнер уходит, анонимный том с данными
остаётся сиротой — по одному на каждый прогон CI.
- ci.yml / ci-tradein.yml: `docker rm -f "$CI_PG"` → `docker rm -fv` (4 места).
В deploy-*.yml тот же вызов применяется к БОЕВЫМ контейнерам — туда -v
добавлять нельзя, снесло бы тома с данными прода. Не тронуто.
- ops/docker-prune.sh — страховка на то, что runner не убрал за собой.
Удаляет: остановленные контейнеры старше 24ч, висячие образы старше 7 суток
и тома-сироты ТОЛЬКО двух известных форм (64-символьный hex и
FORGEJO-ACTIONS-TASK-*). Именованные тома не трогаются никогда — голый
`docker volume prune` такой разницы не делает, поэтому здесь не используется.
Порядок важен: контейнеры → образы → тома, иначе освободившиеся после
контейнеров тома останутся до следующего запуска (на этом я и споткнулся
при ручной чистке — после prune осталось ещё 78 сирот).
Проверено на проде: DRY_RUN, затем боевой прогон, затем повторный — no-op.
Тома 220 → 15, все используются, освобождать нечего. Диск 76% → 67%.
Cron поставлен: вс 04:00 UTC (свободный слот рядом с бэкапами).
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'ом, деплой не падает.
Review-bot PR #510 нашёл P0: `set_config(name,value,is_local)` возвращает
установленное значение, psql печатает rows на stdout без `-q`/`-A`/`-t`,
итог — фактический pw появляется в Forgejo Actions deploy logs (retained,
visible всем с repo read access).
Fix: оборачиваем оба `SELECT set_config(...)` в `\o /dev/null` / `\o`
brackets. Output mutes только для этих строк — NOTICE-сообщения из DO block
(idempotency signal 'tradein_fdw_reader password set') остаются видимыми.
Также добавлен rollback hint в комментарии: НЕ revert файл (вернёт сломанный
:'pw' внутри $$), а unset GENDESIGN_FDW_PASSWORD в .env.runtime на VPS.
psql :'pw' substitution НЕ работает внутри dollar-quoted блока $$..$$ — это
правило psql, не bug. Symptom: deploy 2026-05-24 после merge PR #503 упал с
'syntax error at or near :' on LINE 4 of set_tradein_fdw_password.sql, :'pw'
дошёл до сервера как literal вместо интерполяции.
Fix: передаём password в DO через session GUC (set_config) — psql интерполирует
:'pw' ВНЕ dollar quote, внутри читаем через current_setting(). Очищаем GUC
после выполнения (defense-in-depth).
- backend/alembic/* — alembic infra (env.py, script.py.mako). versions/ empty
for now; first migration goes in Stage 2a when models are finalized.
- backend/Dockerfile: bake alembic.ini + alembic/ into the image so
`docker compose exec backend alembic upgrade head` works in prod.
- backend/db/init/99_drop_unused_extensions.sql + bind-mount in both compose
files: drops postgis-image's TIGER/topology/fuzzystrmatch on fresh volumes.
- .pre-commit-config.yaml + pre-commit in dev deps: ruff/prettier on commit
to stop CI failures like UP035 from leaking out.
- ops/backup.sh, ops/restore.sh: pg_dump cron script with optional Selectel S3
upload. 7-day local retention. Restore guard: requires RESTORE_CONFIRM=yes.
- Makefile: new targets `make migration NAME=...`, `make pre-commit-install`.
- backend/.env.example: SENTRY_DSN comment with sentry.io reference.