Как только канал алертов реально включился (#3126), профиль alerts впервые
дошёл до проверки конфига — и деплой встал:
amtool: error: failed to validate 1 file(s)
Checking '/tmp/am.yml' FAILED: open /tmp/am.yml: permission denied
Отрендеренный конфиг пишется с правами 600 и принадлежит деплой-пользователю,
а и amtool, и сам Alertmanager в образе prom/alertmanager работают под nobody
(65534). Прочитать чужой файл 600 они не могут. Проверка падала не на
содержимом конфига, а на доступе к нему — и контейнер после подъёма упал бы
ровно там же.
Дефект не поймали раньше по понятной причине: без токена профиль alerts не
включался, и эта ветка не исполнялась НИ РАЗУ с момента появления гейта в
#3111. Проверка, которая никогда не запускалась, ничем не отличается от
отсутствующей — это ровно тот класс тихой поломки, ради которого весь стек и
заводится.
Права не ослабляем: в файле лежит токен бота. Вместо chmod 644 (который внёс
бы токен в список файлов, читаемых любым локальным пользователем машины)
отдаём файл во владение 65534 одноразовым контейнером от root — passwordless
sudo на хосте нет, а бинд-маунт правит host-инод напрямую. Доступ остаётся
ровно у того, кто конфиг читает.
Следствие, которое легко проглядеть: после смены владельца `>` в этот файл
на следующем деплое уже не запишет, поэтому добавлен rm перед рендером —
каталог принадлежит деплой-пользователю, пересоздать файл он может.
Стек наблюдаемости был готов ещё вчера, но Alertmanager не поднимался:
профиль alerts включается, только когда заданы METRICS_TELEGRAM_BOT_TOKEN и
METRICS_TELEGRAM_CHAT_ID, а читались они ИСКЛЮЧИТЕЛЬНО из файла окружения на
инфраструктурной машине. То есть включить алерты можно было только правкой
прод-файла руками по ssh — в обход репозитория, без следа в истории и без
возможности сделать это из CI.
Именно это и держало задачу открытой дольше нужного. Цена промедления
измерена: 27.08 продукты лежали 10 часов, и ни одно звено оповещения не
сработало (#3119); в тот же день сутки не доезжал деплой, и об этом тоже
никто не узнал (#3029).
Теперь три переменные форвардятся в ssh-шаг из секретов Actions. Порядок
разрешения сохранён осознанно: инжектированные значения ставятся ДО того,
как скрипт подхватит окружение машины, поэтому хост, если ключи заданы на
нём, переопределяет секреты — последнее слово остаётся за машиной, а
секреты работают как разумный дефолт.
Канал проверен вживую: бот MERAsupport_bot, форум «МЕРА», тема «алерты»
(message_thread_id=158) — пробное сообщение доставлено.
27.08 прод сутки жил на позавчерашнем коммите, и это нашлось только руками.
Замена IP сервера (#3110) осиротила секрет DEPLOY_HOST: deploy.yml падал на
`dial tcp ***:***: i/o timeout`, при этом CI оставался зелёным, PR
продолжали мержиться, а deploy-infra.yml исправно обновлял Beget и создавал
впечатление, что всё в порядке.
Проверок, которые спрашивают не «прошёл ли прогон», а «доехал ли код», не
было ни одной. Этот сторож — ровно такая: ежечасно читает HEAD с прод-хоста
и сверяет с tip main.
Три исхода вместо двух. «Хост не ответил» (2) отделён от «на хосте не тот
код» (1) — это разные аварии с разной первой командой в разборе, и сегодня
погорели именно на их смешении: недоступность выглядела как обычный красный
прогон. Льготный период 30 минут гасит ложную тревогу на деплое, который
ещё в полёте: ежечасный сторож неизбежно попадёт в окно между мержем и
концом выката, а сторож, которого научились игнорировать, хуже отсутствующего.
Логика вынесена в scripts/check-deploy-drift.sh и не ходит по сети — SSH
живёт в workflow, где секреты. Благодаря этому тест ИСПОЛНЯЕТ настоящий
скрипт, а не пересказывает его: ошибка в самом bash видна только при запуске.
Read-only: один ssh и git rev-parse, ничего не деплоит.
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый
188.246.224.93 фильтровался российскими операторами и не открывался ни с
домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет —
проверено с машины владельца после переключения DNS.
Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался
прежний (разбор и восстановление — #3119). Здесь только то, что осталось в
репозитории.
Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh:
скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча
настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты
комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона;
они не исполняются, но именно по ним сверяются при переезде, и разошедшийся
адрес в них дороже, чем кажется.
Бот, которым шлются тревоги, — тот же, что пересылает сообщения поддержки, а
его чат форумный. Без message_thread_id Alertmanager кладёт тревоги в общую
тему, вперемешку с клиентской перепиской.
Поле поддерживается: проверено amtool check-config на том же образе, что
поднимается в проде (prom/alertmanager:v0.28.0). Схема Alertmanager строгая и
неизвестные поля отвергает, так что успешная проверка означает именно
поддержку, а не молчаливое игнорирование.
Подставляется ЦЕЛАЯ СТРОКА, а не значение: envsubst не умеет условий, и при
шаблоне вида `message_thread_id: ${TOPIC_ID}` незаданный топик дал бы
`message_thread_id:` без значения. Это не деградация - Alertmanager с таким
конфигом не стартует вовсе, то есть алертинг исчезает целиком. Деплой
формирует либо всю строку с отступом, либо пустую.
Топик необязателен: без него поле отсутствует, алерты уходят в общую тему,
поведение прежнее.
Попутно добавлена проверка конфига через amtool ДО подъёма стека - по образцу
`caddy validate` ниже в этом же файле. amtool берётся из того же образа, что и
сам Alertmanager, иначе проверялась бы не та версия схемы. Битый конфиг теперь
роняет деплой громко, а не выключает алертинг тихо.
Тесты (4) рендерят шаблон обоими способами и разбирают результат как YAML -
проверяется фактический конфиг, а не наличие нужных слов в тексте. Отдельно
проверено, что переменная объявлена в списке envsubst: забыть её - значит
оставить в конфиге литерал плейсхолдера.
Фальсификация: на исходных файлах краснеют 3 из 4; проходит только тест,
фиксирующий сохранённое поведение при незаданном топике. tests/ops целиком -
35 passed.
Джоба agent-apps упала целиком:
error while interpolating services.postgres-exporter-infra.environment.
DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value
INFRA_EXPORTER_DSN нужен экспортеру с profiles: ["infra"], который на
продуктовом хосте не поднимается вовсе. Но compose интерполирует ВЕСЬ файл
до фильтрации по профилям, поэтому `${VAR:?}` роняет команду из-за чужой
переменной. Вместе с агентом не поднялись alloy, node-exporter и cadvisor,
которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент -
на двух продуктовых переменных.
Второй дефект в той же цепочке: GENDESIGN_EXPORTER_DSN тоже отсутствовал.
setup-metrics-exporter-dsn.sh читает только runtime-файл окружения бэкенда, а
DATABASE_URL и TRADEIN_DATABASE_URL живут в основном. Значит подстановка
всегда была пустой, add_key печатал "нечем заполнить" и выходил с кодом 0 -
мягкий пропуск встречался с жёстким требованием compose.
Стало:
- compose: `:-` вместо `:?` у трёх DSN. Интерполяция больше не может упасть.
- deploy-metrics.yml: профиль экспортеров включается, только если нужные ЭТОЙ
роли DSN заполнены; иначе ::warning и агент поднимается без экспортера.
Громкость не убрана, а перенесена туда, где роль известна. Тот же приём, что
уже применён к Alertmanager в джобе server.
- setup-metrics-exporter-dsn.sh: читает оба файла окружения (базовый, затем
runtime - он перекрывает). Пишет по-прежнему только в runtime, лишних копий
пароля не заводит.
Почему `:-` не ослабление: пустой DATA_SOURCE_NAME поднял бы экспортер,
который молча не отдаёт метрик, - ровно тот тихий отказ, ради которого весь
стек и заводится. Поэтому пустой DSN теперь означает "профиль не включаем",
а не "поднимаем пустым".
Известное следствие, отмеченное в коде: INFRA_EXPORTER_DSN не собирает никто -
скрипт знает только про GENDESIGN_/TRADEIN_ и работает на продуктовом хосте.
Пока это так, инфраструктурный агент будет честно предупреждать, что метрик
Postgres инфры нет, вместо того чтобы падать целиком.
Тесты (3) структурные, проверяют оба конца инварианта: обязательности не
вернулись в compose; каждая DSN-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
Стек наблюдаемости не поднялся ни на одном хосте после мержа #3099: джоба
server упала, agent-apps и agent-infra пропустились как зависимые.
Причина (задача 23657, 26.08 08:55):
err: ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres
setup-metrics-grafana-role.sh искал привилегированную роль перебором трёх
имён - glitchtip, forgejo, postgres. Ни одно не совпадает ни с одним реальным
кластером проекта: infra-postgres -> infra, gendesign-postgres-1 -> gendesign,
tradein-postgres -> tradein. Комментарий над перебором сам предупреждал, что
"угадывать postgres неверно", и дальше шло угадывание.
Замер на живом контейнере 26.08 (read-only, ничего не создавалось):
POSTGRES_USER изнутри контейнера: infra
glitchtip - отказ, forgejo - отказ, postgres - отказ, infra - 1
Стало: имя берём из POSTGRES_USER самого контейнера - это та переменная,
которой роль и создана при initdb, то есть источник истины. Прежний список
оставлен ПОСЛЕ него запасным путём для кластера не из образа postgres.
Попутно - глоб в paths деплоя. Воркфлоу запускает ТРИ setup-скрипта, а в
триггере стоял только setup-metrics-secrets.sh: правка двух остальных не
заводила выкат, и на хосте молча оставалась старая версия. Тот же класс, что
#2203 закрыл глобом ops/*.sh. Добавлен тест, который сверяет запускаемые
скрипты с шаблонами paths - на исходном воркфлоу он краснеет, указывая на
setup-metrics-exporter-dsn.sh.
Тесты (6) исполняют РЕАЛЬНЫЙ скрипт с подставным docker и проверяют
фактический выбор роли, а не наличие правильных слов в комментарии.
Фальсификация: на исходном коде краснеют 3 из 5 ролевых тестов; проходят
только те два, что фиксируют сохранённое поведение (запасной перебор и
громкая ошибка при отсутствии привилегий). tests/ops целиком - 28 passed.
Follow-up к #3103. Прод лёг на ~30 минут потому, что PR завёл
`import ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил
bind-монты этих файлов в docker-compose.prod.yml.
Соседний гард `caddy validate` эту дыру не ловит принципиально: он копирует
каталог caddy/ целиком (`docker cp caddy ...`), а на проде смонтированы только
отдельные файлы плюс два каталога. Расхождение между «что лежит в репозитории»
и «что реально видит контейнер» видно только если сверять с маунтами.
check-caddy-snippet-mounts.py разбирает bind-монты сервиса caddy:, резолвит
каждый `import` в Caddyfile / caddy/sites/*.caddy / caddy/*.caddy.snippet
относительно КОНТЕЙНЕРНОГО пути импортирующего файла и падает, если цель не
покрыта ни одним маунтом. Именованные сниппеты `(name) { }` пропускаются,
для glob/placeholder-импортов (`caddy/sites/{$CADDY_SITES:*}.caddy`)
проверяется каталог. --selftest воспроизводит ровно баг #3102.
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах,
единственный канал наблюдения — journald, единственный сигнал об аварии —
исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе:
задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь
месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль
newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992).
Grafana не заменяет GlitchTip: ошибки остаются там. Grafana OSS не принимает
Sentry DSN ни одним компонентом, а скрубберы в before_send — требование 152-ФЗ.
Здесь появляется другой класс данных: ряды и алерты по трендам.
Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона
на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом
сказать, а не лечь вместе с ним.
Транспорт push, а не pull: агент на Poincare шлёт remote_write и логи исходящим
HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443.
При обрыве канала Alloy копит в WAL и досылает — pull-скрейп в той же ситуации
терял бы точки именно в аварии, ради которой мониторинг и нужен.
Два контура доступа с разными учётками. Пароль приёмника по построению лежит
открытым на продуктовом хосте, значит его компрометация неизбежна вместе с
хостом; будь это учётка витрины, утёк бы и доступ к дашбордам.
GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6
stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в
grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена
на живой базе: колонка времени называется timestamp, а не received, и отдельной
таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue.
Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не
должен на нём стоять. Деплой предупреждает, что уведомлять пока некому.
Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus
задана и по времени и по размеру, retention_enabled у компактора Loki (без него
retention_period не работает вовсе), путь к журналу и запуск Alloy от root
(иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки
(на этом хосте тот же Caddy держит git, errors и obsidian).
Refs #3078
Deep-review BLOCK на PR #3011: обе правки чинили заявленный симптом только
частично.
077: гард считал pending-строки по source='rosreestr' AND dedup_hash ~
md5-паттерн и пропускал backfill, только если таких строк 0. На чистой БД
они есть — 003_seed_deals.sql сеет синтетические сделки с тем же паттерном,
значит pending > 0 уже на пустом томе, и миграция всё равно падала на
"user mapping not found" (воспроизведено в CI run 8257). Первичный гард
теперь проверяет напрямую наличие USER MAPPING для gendesign_remote
(идиома из app/core/fdw.py:57-62), счётчик pending оставлен вторым —
экономит обращение к FDW, когда мигрировать уже нечего.
deploy-tradein.yml: цикл ожидания готовности postgres ходил по unix-сокету
(pg_isready без -h). На пустом томе временный init-сервер отвечает на
сокете, пока docker-entrypoint-initdb.d ещё прогоняет цепочку миграций —
проба зеленела посреди initdb. Добавлен -h 127.0.0.1 (тот же приём уже
есть в ci-tradein.yml:157) — TCP открывается только после полного
завершения initdb.d.
Отдельно ужесточён sentinel baseline-детекции: раньше «схема уже накачена»
проверялась одной таблицей listings (миграция 002, почти голова цепочки).
Если бы гонка готовности когда-нибудь вернулась, listings был бы уже
создан, а хвост цепочки — ещё нет, и baseline тихо пометил бы недостающие
миграции применёнными без прогона. Теперь проверяются оба конца — listings
(голова) и houses_geog_gist_idx, индекс из миграции 270 (хвост); при
несовпадении (ровно один конец на месте) деплой падает громко с explicit
ошибкой вместо угадывания.
Блокер переезда (#2990). Чистый старт на пустом томе падал: 077 читает foreign
table gendesign_rosreestr_deals, а USER MAPPING создаёт бэкенд при старте
(app/core/fdw.py), то есть ПОСЛЕ docker-entrypoint-initdb.d. Контейнер не
поднимался вообще. Путь «пустой том» на реальном железе не исполнялся ни разу,
а CI этот файл явно пропускал — гейт, который должен был поймать, был ослаблен.
Проверено по всем 14 миграциям, упоминающим FDW-таблицы: читает ровно одна —
077. Остальные только CREATE/DROP FOREIGN TABLE и COMMENT, им ни USER MAPPING,
ни связь с чужой БД не нужны.
077 не удалена, а сделана самозащитной: гард считает строки в md5-форме и
выходит раньше обращения к FDW, если мигрировать нечего. На чистой БД таких
строк нет по определению. Удаление файла было бы неверным — прод помнит
миграции по bare-filename в _schema_migrations, и
test_applied_migration_is_not_renamed_or_deleted падает на удалении.
Исключение в ci-tradein.yml снято: теперь цепочка применяется целиком, то есть
CI сам стал репетицией чистого старта.
Отдельно закрыт тихий отказ в deploy-tradein.yml. Ветка baseline срабатывала по
одному лишь отсутствию _schema_migrations, а это состояние неоднозначно: так
выглядит и наполненный прод до внедрения tracking, и пустая БД нового сервера.
Во втором случае baseline пометил бы все миграции применёнными, ни одной не
прогнав, и деплой уехал бы зелёным на пустой схеме. Добавлен sentinel по
listings: пусто → baseline пропускается, цепочка применяется с нуля.
Refs #2990, #2989
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
триггеры уже есть, дублирования нет.
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.
## 1. Город предлагали выбрать, но отвечать по нему не умели
Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:
предлагали, но не отвечали: Серов
отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда
Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:
«Этот адрес вне области, по которой мы собираем данные.
Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
городов вокруг.»
Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.
Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.
## 2. Подсказки не слушались выбранного города
`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.
Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.
Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.
Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).
## Фикстура теста
`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.
## Мутации
убрать Серов из покрытия (состояние прода) → падает сверка списков
не подставлять город в строку → падает проверка ручки
откат → 21 passed
Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.
`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.
Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.
Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.
Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
## Экран проверки — /estimate
Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).
Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».
## Короткие адреса
Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.
Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.
Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.
## Три дефекта, найденных на живом сайте
1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
--b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.
## Периметр и приватность
Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.
П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).
## Проверено
vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.
Мутации структурных гейтов:
убрать --b2c-accent-text из b2cVars → падает тест палитры
убрать /estimate из @meraPages → падает тест маршрутов
добавить импорт next/link → падает тест basePath
откат → 14 passed
Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.
Refs #2894, #2895
main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с
fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу,
слились автоматически.
Единственное ручное разрешение — _manifest_applied.txt (modify/delete):
main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть
предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не
из ручного манифеста, который отставал и по построению не мог покраснеть
(#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками).
Слить актуальный 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/
не наступил на них третий раз.
Диск был занят на 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 (свободный слот рядом с бэкапами).
Конфликт modify/delete по tradein-mvp/backend/data/sql/_manifest_applied.txt
разрешён удалением — удаление и есть предмет PR. За трое суток в main дописали
три имени (240/250/251); дописывать их некуда: файла больше нет, а гейт
tests/test_migration_numbering.py берёт эталон применённого из origin/main.
Проверено, что удаление ничего не оставляет без потребителя: манифест не *.sql,
цикл миграций в deploy-tradein.yml и bootstrap схемы в ci-tradein.yml берут
glob '*.sql', новый scripts/check-migration-lock-timeout.py — тоже.
Замер дрейфа на 27e199e3: 216 имён в манифесте против 232 файлов, отставание
16 (было 15 на 07.08). Старый гейт на этом дереве: 4 passed.
# Conflicts:
# tradein-mvp/backend/data/sql/_manifest_applied.txt
Отдельный шаг `git fetch origin main` в backend-тестовых job'ах ронял прогон:
из job-контейнера git.gendsgn.ru:443 недостижим (run 6977, connection refused
за 5 мс), сеть есть только у самого checkout. Шаг и не был нужен — в логе того
же прогона видно, что при fetch-depth: 0 checkout идёт refspec'ом
`+refs/heads/*:refs/remotes/origin/*`, то есть origin/main появляется сам.
Refs #2683
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым»
любой файл, которого нет в списке, а новые файлы от списка освобождены
(докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый
файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и
то же, поэтому дрейф был не пропуском проверки, а её штатным исключением.
Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные —
через сутки после того, как #2692 догнал список руками.
Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml
применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл
доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и
дрейфовать становится нечему.
Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с
ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией,
смерженной после ветвления, находится. Проверено на живом PR #2754
(234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown
из main): старый гейт зелёный, новый красный.
Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а
не с origin/main: иначе ветка недельной давности краснела бы за чужие
миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная.
CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo
не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет
ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он
намеренно красный, а не тихо пропущенный.
Контракт сведён к одной формулировке — докстринг test_migration_numbering.py;
шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules
удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без
`-r` печатает каталог, а не файлы.
Refs #2683