config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
## Context
|
||||
|
||||
Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа:
|
||||
`telegram.bot_token = ""` означает и «вход выключен намеренно», и «ключа нет».
|
||||
Разрез объявлен решением владельца от 2026-08-13 и записан в
|
||||
[ADR-2026-08-13-telegram-outage-does-not-block-startup](../../../docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md);
|
||||
здесь меняется не он, а то, **откуда** сервис узнаёт намерение владельца.
|
||||
|
||||
Ограничения, с которыми считаемся:
|
||||
|
||||
- файл настроек на сервере собирает Ansible из `pet-project-server`, и ключ
|
||||
доступа приезжает туда из внешнего хранилища секретов. Значение, потерянное при
|
||||
сборке, неотличимо от решения владельца;
|
||||
- инвариант «секрет не покидает конфиг» — ни сообщение об отказе старта, ни
|
||||
запись журнала не несут значения ключа. Проверка секции `[auth]` уже устроена
|
||||
так и служит здесь образцом;
|
||||
- локальный прогон боевым токеном запрещён, и подъём без Telegram — его обычный
|
||||
режим. Он не должен стать труднее.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- признак включения объявляет намерение, ключ доступа означает только доступ;
|
||||
- включённый вход без ключа роняет старт с внятным сообщением;
|
||||
- выключенный вход сообщается записью журнала, не поднимая уровень до
|
||||
предупреждения;
|
||||
- локальный прогон одним входом остаётся одной строкой настройки.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- приём записи из Telegram, белый список и доставка ответов;
|
||||
- чистка прочих путей, где секрет мог бы уехать наружу: работа закрывает один
|
||||
названный ревью — текст отказа разбора файла настроек;
|
||||
- единое место проверки настроек для всех секций: `[auth]` и `[telegram]` пока
|
||||
проверяются каждая своим методом, и сведение их в один проход — отдельная
|
||||
работа;
|
||||
- правка шаблона настроек в `pet-project-server`: его правит человек, здесь он
|
||||
только назван.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Признак обязателен, умолчания у него нет
|
||||
|
||||
Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и процесс
|
||||
выходит с ошибкой настройки. Решение владельца от 2026-08-13.
|
||||
|
||||
Довод: умолчание — это угаданное намерение, а признак заводится ровно затем,
|
||||
чтобы намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
|
||||
стороны, и любое умолчание делает одну из двух ошибок тихой.
|
||||
|
||||
Альтернативы и причина отказа:
|
||||
|
||||
- **умолчание «включён»** — отвергнуто владельцем: файл без признака работал бы
|
||||
«как-нибудь», и разница между объявленным и угаданным намерением исчезала бы
|
||||
ровно там, где её завели;
|
||||
- **умолчание «выключен»** — отвергнуто и по тому же доводу, и отдельно: первый
|
||||
же подъём после выкладки выключил бы бота молча. Это исход, против которого
|
||||
написано само требование.
|
||||
|
||||
Цена решения — порядок выкладки: шаблон настроек обязан получить признак раньше
|
||||
образа. Она названа в разделе «Migration Plan» и на чекпоинте.
|
||||
|
||||
### Отсутствие ключа ловит загрузчик, пустой ключ — проверка секции
|
||||
|
||||
Разрез идёт по тому, **о чём судим**. Отсутствие ключа — свойство файла, и
|
||||
видит его только разбор: `toml.DecodeFile` отдаёт `MetaData`, и `IsDefined`
|
||||
отвечает, был ли ключ в файле вообще. Значение поля — свойство настройки, и
|
||||
судит его `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`.
|
||||
|
||||
Альтернатива — сделать поле `*bool` и свести обе проверки в `Validate()` —
|
||||
отвергнута: указатель переживает проверку и уезжает к потребителям, где `nil`
|
||||
уже невозможен, но выглядит возможным. Читатель настройки платит за форму,
|
||||
нужную одному разбору.
|
||||
|
||||
`MetaData` из `LoadConfig` наружу не отдаётся: отказ формируется на месте, и
|
||||
знание о разборе не растекается.
|
||||
|
||||
### Проверка ключа живёт в настройках, а не в сборке входа
|
||||
|
||||
`TelegramConfig.Validate()` зовётся из `main.go` сразу после загрузки, рядом с
|
||||
проверкой секции `[auth]`, роняет процесс, называет **имя** незаполненного ключа
|
||||
и не касается значения.
|
||||
|
||||
Альтернатива — оставить проверку внутри сборки клиента, как сейчас, — отвергнута:
|
||||
сборка ходит в сеть, и отказ настройки смешался бы там с отказом Telegram. Читать
|
||||
разрез пришлось бы по типу ошибки, а не по месту.
|
||||
|
||||
### Ветка «токен пуст» в разборе сборки меняет исход
|
||||
|
||||
Сегодня `telegramFromBot` на `telegram.ErrEmptyToken` отдаёт мягкий исход: сервис
|
||||
поднимается без Telegram. После разведения это состояние по построению
|
||||
недостижимо — проверка настроек ловит его раньше, — но ветку не убираем: она
|
||||
получает исход «ошибка настройки, старт роняется» и встаёт рядом с отказом Bot
|
||||
API.
|
||||
|
||||
Причина: удалённая ветка оставила бы пустой ключ падать в общий случай `err !=
|
||||
nil`, то есть в «недоступность», и обход проверки настроек дал бы тихий подъём —
|
||||
ровно то, что мы убираем. Ветка, недостижимая по построению, но дающая верный
|
||||
исход, дешевле ветки, дающей неверный.
|
||||
|
||||
Единая точка `telegram.NewBot` и значение `telegram.ErrEmptyToken` остаются как
|
||||
есть: они держат инвариант «Bot API только через нашего клиента».
|
||||
|
||||
### Отказ разбора файла настроек говорит своими словами
|
||||
|
||||
Найдено ревью дизайна и чинится этой же работой по решению владельца.
|
||||
|
||||
`toml.DecodeFile` отдаёт отказы двух семейств, и значения несёт **только одно**:
|
||||
|
||||
- `toml.ParseError` — сюда сведены отказы лексера и разбора значения, и его поле
|
||||
`Message` собирается из разбираемого куска (`Invalid float value: %q`,
|
||||
`invalid duration: %q`, `%v is out of range`). Незакавыченный токен из криво
|
||||
собранного шаблона выкладки попадает в текст целиком. Из этого отказа берём
|
||||
**строку, столбец и последний ключ** — они безопасны, — а `Message` не берём;
|
||||
- прочие отказы декодера (несовпадение типов, неподдерживаемый тип) собираются
|
||||
из **имён ключей и имён типов**, значений в них нет. Их текст берём как есть:
|
||||
выбрасывать его значило бы платить разборчивостью отказа там, где платить не за
|
||||
что.
|
||||
|
||||
Отвергнутые альтернативы:
|
||||
|
||||
- **выбросить текст обоих семейств** — просто и закрыто наглухо, но за
|
||||
несовпадение типов (`port = "8080"`) владелец получал бы «файл не
|
||||
разбирается» без единого намёка, а значения там нет по построению;
|
||||
- **вычищать значения из текста** — вычищать не с чем: разбор не состоялся, и
|
||||
значений в настройках ещё нет;
|
||||
- **брать `Message`, когда последний ключ не секретный** — перечень секретных
|
||||
ключей живёт в конвенции и разошёлся бы с кодом молча, а расхождение здесь
|
||||
означает утечку.
|
||||
|
||||
**Спеки это не меняет, и требования под себя не заводит.** Норма уже записана и
|
||||
сильнее спеки: инвариант «Секрет не покидает конфиг» в `CLAUDE.md` со степенью
|
||||
`critical`. Работа приводит код в соответствие с записанным, а не заказывает
|
||||
новое поведение. Форма записи отказа уезжает в конвенцию настроек, раздел
|
||||
«Секреты», — там её дом.
|
||||
|
||||
**Дом нормы назначен явно, и это выбор, а не умолчание.** Загрузка настроек не
|
||||
принадлежит ни одной заведённой capability: `intake` сама объявляет, что нормирует
|
||||
наличие входа, а не приём; `access`, `pipeline` и `storage` к разбору файла
|
||||
отношения не имеют. Заводить capability подъёма ради одного семейства отказов
|
||||
дороже выигрыша, а вписывать разбор настроек в `intake` значит переносить туда
|
||||
чужое. Поэтому дом нормы — **инвариант `CLAUDE.md` плюс конвенция
|
||||
`docs/conventions/config.md`**, и спеки загрузку настроек не нормируют.
|
||||
Найдено ревью кода; цена решения в том, что при следующей ревизии семейства
|
||||
отказов спека не скажет ничего и опорой будут конвенция и проверки.
|
||||
|
||||
### Выключенный вход — уровень `INFO`
|
||||
|
||||
Предупреждение говорит «случилось не то, что ты просил». Выключенный вход — ровно
|
||||
то, что просил владелец, и на каждом локальном прогоне это давало бы шум,
|
||||
неотличимый от настоящей недоступности. Недоступность остаётся `WARN`.
|
||||
|
||||
Признак поднятости входа (`IntakeUpGauge`) выставляется во всех случаях, включая
|
||||
выключенный: наблюдателю нужен ответ «работает ли вход сейчас», а не «почему».
|
||||
|
||||
### Сборка входа получает настройки секцией, а решение о выключенном входе — шов
|
||||
|
||||
`buildTelegram` принимает `config.TelegramConfig` целиком вместо одного токена:
|
||||
решение «поднимать или нет» читает оба поля, и разносить их по двум аргументам
|
||||
значит заводить два места, где их сверяют.
|
||||
|
||||
Само решение уезжает в `telegramFromConfig(cfg, newBot, logger)`, где `newBot` —
|
||||
параметр-функция сборки клиента; `buildTelegram` подставляет туда
|
||||
`telegram.NewBot`. Иначе главное утверждение выключенного входа — **обращения к
|
||||
Telegram не уходит ни одного** — проверить нечем: `telegram.NewBot` держит адрес
|
||||
Bot API внутри, и проверка, судящая по исходу, останется зелёной и тогда, когда
|
||||
ветка выключенного входа встанет **после** обращения. Тогда прогон с заполненным
|
||||
ключом ходил бы в живой Telegram боевым токеном, а проверка этого не заметила бы.
|
||||
|
||||
Шов — параметр-функция, а не интерфейс: реализация у него одна, и вводить ради
|
||||
неё тип значит заводить понятие там, где хватает подписи. Прецедент в проекте
|
||||
свой и того же рода — `telegram.newBot(token, endpoint, logger)` принимает адрес
|
||||
отдельно ровно затем, чтобы проверка не ходила в сеть.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Файл настроек на сервере отстал от кода** → сервис не поднимется вовсе:
|
||||
признака в файле нет, загрузка кончается отказом. Это главный риск работы, и
|
||||
снимается он порядком выкладки — сперва шаблон настроек, потом образ. Отказ
|
||||
громкий, называет ключ и виден в первую же минуту; молчаливая потеря бота
|
||||
обошлась бы дороже, но порядок соблюсти обязан человек.
|
||||
- **Локальный файл настроек отстал от кода** → тот же отказ и та же починка:
|
||||
одна строка `enabled = false`.
|
||||
- **Проверок настроек стало две вместо одной** → расхождение между ними ловится
|
||||
только глазами. Сведение в один проход названо Non-Goal и остаётся работой на
|
||||
потом.
|
||||
- **Ошибочный `enabled = false` из шаблона выкладки** → работа закрывает одно
|
||||
русло молчаливой потери бота (потерян ключ доступа) и оставляет второе:
|
||||
признак, отрендеренный ложным из-за пропущенной переменной, отличим от решения
|
||||
владельца **только записью журнала** — `INFO` против `WARN`. Признак
|
||||
поднятости входа тут не помощник: он равен нулю и при выключенном входе, и при
|
||||
недоступности Telegram, то есть от аварии этот случай не отделяет, а
|
||||
собственного оповещения у проекта нет вовсе. Сервис поднимается штатно, и
|
||||
владелец узнаёт о беде от молчащего бота — тем же способом, что и прежде.
|
||||
Ненаписанный риск читается как несуществующий, поэтому он назван здесь: ключ
|
||||
`enabled` в шаблоне выкладки критический.
|
||||
- **Остаточный риск утечки при смене версии библиотеки разбора** → разрез ниже
|
||||
опирается на то, какие семейства отказов несут значения сегодня. Версия
|
||||
библиотеки, переложившая значение в другое семейство, вернёт утечку молча.
|
||||
Держится это проверкой на поломанной строке секретного ключа; она же краснеет
|
||||
при таком переносе.
|
||||
- **Ветка, недостижимая по построению**, живёт в коде и её нельзя проверить
|
||||
через настройки → проверяется напрямую на уровне разбора исхода сборки, как
|
||||
уже устроены соседние ветки.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Порядок обязателен, и нарушение его роняет сервис на сервере.
|
||||
|
||||
1. Код и образец настроек едут вместе: `config.dist.toml` получает
|
||||
`enabled = false` при пустом ключе доступа — это состояние свежей локальной
|
||||
установки.
|
||||
2. **Раньше накатки образа** шаблон настроек в `pet-project-server` получает
|
||||
строку `enabled = true`, и файл на сервере перерисовывается. Правит человек,
|
||||
отдельно от этой работы; пока правки нет, новый образ на сервер не едет.
|
||||
3. Только после этого едет образ.
|
||||
|
||||
Откат: вернуть прежний образ. Файл настроек с ключом `enabled` прежний код
|
||||
разбирает без отказа — лишний ключ TOML разбор не роняет, он просто не читается,
|
||||
и бот поднимается по непустому токену.
|
||||
|
||||
**Откат при выключенном входе допустим только на образ от 2026-08-13 и новее.**
|
||||
На более старом состояния «сервис поднят, бот опущен» не существует вовсе:
|
||||
пустой ключ роняет старт, негодный роняет старт, годный поднимает бота. Откат
|
||||
туда делают с непустым годным ключом, приняв, что бот поднимется; рецепт ниже на
|
||||
таком образе ведёт к выходу с кодом 1 до открытия порта.
|
||||
|
||||
**Один случай отката требует и отката настроек** — `enabled = false` при
|
||||
заполненном ключе доступа, то самое состояние, ради которого два значения и
|
||||
разводятся. Прежний код признака не видит и поднимает бота, то есть отменяет
|
||||
решение владельца молча. Если вход был выключен потому, что бот с этим токеном
|
||||
поднят где-то ещё, два процесса поделят один длинный опрос и часть ответов до
|
||||
людей не дойдёт — прямо тот вред, который называет запрет «Боевым токеном бота не
|
||||
запускаться». Откат в этом состоянии начинается с очистки ключа доступа.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Открытых нет: умолчание признака решено владельцем 2026-08-13 — признак
|
||||
обязателен, умолчания у него нет.
|
||||
Reference in New Issue
Block a user