CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50), живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses - users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR - «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟 - миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres - SPEC A-3a, ADR-017, DEMO.md, .env.example Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
22 KiB
Markdown
172 lines
22 KiB
Markdown
# Архитектурные решения (ADR)
|
||
|
||
Формат записи:
|
||
|
||
```
|
||
## ADR-NNN: <заголовок>
|
||
**Дата:** YYYY-MM-DD · **Статус:** принято | отменено | заменено ADR-MMM
|
||
|
||
**Контекст:** почему возник вопрос.
|
||
**Решение:** что выбрали.
|
||
**Альтернативы:** что отвергли и почему.
|
||
**Последствия:** что это меняет.
|
||
```
|
||
|
||
---
|
||
|
||
## ADR-001: Хостинг кода и задач — SourceCraft
|
||
**Дата:** 2026-10-12 · **Статус:** заменено ADR-010
|
||
|
||
**Контекст:** нужен репозиторий проекта, трекер задач и CI.
|
||
**Решение:** код бота, issues и CI — на SourceCraft. Загрузки пользователей — в отдельный репозиторий там же.
|
||
**Альтернативы:** GitHub/Gitea — не соответствует постановке преподавателя.
|
||
**Последствия:** CI описывается в формате SourceCraft; бот ходит в SourceCraft по своему deploy-ключу.
|
||
|
||
## ADR-002: Telegram — long polling, а не webhook
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Контекст:** бот должен легко разворачиваться «где угодно», включая ноутбук без белого IP.
|
||
**Решение:** long polling (aiogram `start_polling`).
|
||
**Альтернативы:** webhook — нужен домен, HTTPS и nginx-vhost; выигрыш в задержке для учебной нагрузки несущественен.
|
||
**Последствия:** входящих портов нет; на VPS не нужен vhost. Один экземпляр бота на токен.
|
||
|
||
## ADR-003: Статус CI — polling SourceCraft API
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Контекст:** нужно сообщать пользователю результат CI.
|
||
**Решение:** бот опрашивает `/cicd/runs` и сопоставляет прогон с коммитом по sha; опрос идёт только пока есть незавершённые загрузки.
|
||
**Альтернативы:** исходящие webhooks — у SourceCraft не задокументированы; «CI сам пишет в Telegram» — секрет бота в чужом CI и нет связи с исходным сообщением.
|
||
**Последствия:** задержка статуса до ~10 с; клиент API обязан переживать обрывы (ретраи).
|
||
|
||
## ADR-004: Образы собираются на хосте, реестр — опционально
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Контекст:** у SourceCraft есть container registry (`pkg.sourcecraft.tech`), но его создание требует платёжного аккаунта Yandex Cloud и роли администратора организации.
|
||
**Решение:** и бот, и приложения собираются `docker build` прямо на хосте (как meditron). Переменная `REGISTRY` в будущем переключит деплой на `pull` готового образа.
|
||
**Альтернативы:** registry SourceCraft — зависимость от биллинга; GHCR/Docker Hub — ещё один внешний аккаунт.
|
||
**Последствия:** сборка нагружает хост (на время деплоя); деплой не зависит от внешнего реестра.
|
||
|
||
## ADR-005: Приложения пользователей — на том же хосте, в песочнице
|
||
**Дата:** 2026-10-12 · **Статус:** принято (временно)
|
||
|
||
**Контекст:** единственный доступный сервер — VPS vobot, где работают прод и dev Vobot и meditron.
|
||
**Решение:** запускать на нём же, но в жёсткой песочнице (non-root, read-only, cap-drop ALL, лимиты CPU/RAM/PIDs, без сети по умолчанию) и с общим лимитом 10 приложений.
|
||
**Альтернативы:** отдельный VPS — правильнее по изоляции, но лишняя машина для учебного проекта; self-hosted worker SourceCraft — запуск внутри CI не даёт долгоживущих сервисов.
|
||
**Последствия:** риск побега из контейнера ложится на прод-Vobot. Хост приложений вынесен в конфиг (`RUNNER_HOST`) — переезд на отдельную машину без изменений кода.
|
||
|
||
## ADR-006: Запуск и управление приложениями — через `tgit-runner` с forced command
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Контекст:** и CI, и бот должны запускать/останавливать контейнеры; полный доступ к Docker равен root на хосте.
|
||
**Решение:** единая точка — скрипт `tgit-runner` на хосте; SSH-ключи CI и бота в `authorized_keys` пользователя `tgit-deploy` ограничены `command="tgit-runner"`; скрипт валидирует аргументы. Бот читает состояние через read-only docker-socket-proxy.
|
||
**Альтернативы:** смонтировать docker.sock в бота — компрометация бота = root на хосте.
|
||
**Последствия:** вся логика песочницы — в одном месте и тестируется отдельно.
|
||
|
||
## ADR-007: LLM строит структурированный запрос, а не shell-команду
|
||
**Дата:** 2026-10-12 · **Статус:** принято, дополнено ADR-013
|
||
|
||
**Контекст:** идея — «LLM формирует grep по логам». Логи содержат текст, подконтрольный пользователю (prompt injection).
|
||
**Решение:** LLM возвращает JSON `LogQuery` (источник, период, regex, уровень, лимит) по схеме; бот валидирует и выполняет поиск сам, в Python, с таймаутом regex. Пользователю показывается эквивалентный grep.
|
||
**Альтернативы:** выполнять `grep` от LLM в shell — инъекции команд, ReDoS, доступ к файлам хоста.
|
||
**Последствия:** поиск ограничен возможностями `LogQuery`, зато безопасен и тестируем.
|
||
|
||
## ADR-008: LLM-провайдер — за протоколом, по умолчанию OpenAI-совместимый API
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Контекст:** провайдер не выбран; нужны тесты без сети.
|
||
**Решение:** протокол `LLMProvider` + фабрика по `LLM_PROVIDER`: `openai_compat` (`LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` — подходит YandexGPT, OpenAI, локальная модель) и `mock`.
|
||
**Альтернативы:** привязка к одному вендору — как в Vobot, правило «провайдер за протоколом».
|
||
**Последствия:** выбор конкретной модели — настройка, а не код.
|
||
|
||
## ADR-009: Стек
|
||
**Дата:** 2026-10-12 · **Статус:** принято
|
||
|
||
**Решение:** Python 3.12, uv, aiogram 3, httpx, pydantic-settings, SQLAlchemy 2 async + Alembic, PostgreSQL 16, `regex`, pytest (+ pytest-asyncio, respx), ruff, mypy. Контейнеризация — Docker Compose.
|
||
**Альтернативы:** python-telegram-bot — синхронное наследие в API; SQLite в проде — проще, но Postgres ближе к реальным сервисам и уже привычен по Vobot/meditron.
|
||
**Последствия:** тесты на SQLite через тот же SQLAlchemy.
|
||
|
||
## ADR-010: Gitea — основной репозиторий и трекер, SourceCraft — зеркало + CI/CD
|
||
**Дата:** 2026-10-12 · **Статус:** принято (заменяет ADR-001)
|
||
|
||
**Контекст:** веб-интерфейс SourceCraft работает нестабильно и часть функций отображает плохо; API периодически рвёт соединения. При этом CI/CD на SourceCraft — часть постановки преподавателя.
|
||
**Решение:** код, issues, PR и ревью — в Gitea [`HSE/tgit`](https://gitea.24studbank.ru/HSE/tgit) (как Vobot/meditron). Встроенный push-mirror Gitea синхронизирует репозиторий в SourceCraft на каждый push (+ раз в 8 ч); CI/CD бота и репозиторий загрузок `tgit-uploads` остаются в SourceCraft.
|
||
**Альтернативы:** только SourceCraft — неудобно работать; только Gitea (Gitea Actions) — уходим от постановки; зеркало по SSH — на этом Gitea SSH-зеркала отключены, поэтому HTTPS + токен SourceCraft.
|
||
**Последствия:**
|
||
- В SourceCraft ничего не пушим и не мержим — зеркало перезаписывает его целиком.
|
||
- Триггер `pull_request` в SourceCraft не срабатывает (PR живут в Gitea) → CI бота запускается на `push` во все ветки.
|
||
- SourceCraft берёт триггеры CI из основной ветки → основная ветка `tgit` в SourceCraft — `dev` (в Gitea остаётся `main`); иначе CI включался бы только после продвижения в `main`.
|
||
- Токен SourceCraft хранится в настройках зеркала Gitea → нужен отдельный токен с доступом только к `tgit`.
|
||
- Issues #1–#21 перенесены из SourceCraft с теми же номерами; в SourceCraft закрыты со ссылкой.
|
||
|
||
## ADR-011: Доступ — только по одноразовым QR-приглашениям
|
||
**Дата:** 2026-10-13 · **Статус:** принято
|
||
|
||
**Контекст:** бот коммитит в общий репозиторий и запускает код на сервере — случайный человек, нашедший бота, не должен ничего сделать или заспамить.
|
||
**Решение:** приглашение = deep link `t.me/<bot>?start=<token>` в виде QR; токен 128 бит, одноразовый, 24 ч, в БД — только хэш. Проверка доступа — middleware на каждом апдейте (сообщения и кнопки). Неавторизованным — одна фраза в сутки, дальше игнор; антиперебор 5 попыток/час. Первые админы — `ADMIN_IDS` в `.env`.
|
||
**Альтернативы:** белый список Telegram ID вручную — неудобно (надо узнавать ID); пароль в `/start` — передаётся и утекает как текст; многоразовый QR — одна утечка открывает доступ всем.
|
||
**Последствия:** нужен раздел «Участники» для админов (выпуск QR, список, отзыв); библиотека `segno` для QR.
|
||
|
||
## ADR-012: Командная модель — общий репозиторий, общие сервисы, алерты всем
|
||
**Дата:** 2026-10-13 · **Статус:** принято (заменяет «проекты с владельцами» из SPEC v1)
|
||
|
||
**Контекст:** v2 требует, чтобы алерты получали все авторизованные, а коммит шёл интерактивно по дереву репозитория.
|
||
**Решение:** все участники равноправно работают с одним репозиторием загрузок (`main`) и видят все сервисы; сервис = папка `services/<name>/`; у каждого участника свой черновик; алерты рассылаются всем (кроме 🛡 — только админам).
|
||
**Альтернативы:** проекты с владельцами (v1) — противоречит «алерты всем» и усложняет навигацию правами.
|
||
**Последствия:** права проще (участник/админ); риск, что участник сломает чужой сервис, — принимаем: история коммитов с автором в трейлере, CI-гейт, откат (P2).
|
||
|
||
## ADR-013: Агент логов — фильтр, LLM-страж и планировщик раздельно; логи в LLM не передаются
|
||
**Дата:** 2026-10-13 · **Статус:** принято
|
||
|
||
**Контекст:** промт пользователя может быть попыткой сломать или взломать систему; логи содержат текст, подконтрольный сервисам (injection).
|
||
**Решение:** конвейер из пяти шагов (SPEC §4.3.1): детерминированный фильтр → отдельный LLM-вызов «страж» с JSON-вердиктом `safe|attack` (невалидный ответ = `attack`) → отдельный LLM-вызов «планировщик», который получает только промт и список контейнеров и возвращает `LogQuery` → валидация → выполнение в Python. Пользователю показывается итоговый grep и найденные строки. Атака → отказ + алерт админам.
|
||
**Альтернативы:** один вызов LLM с инструкцией «откажись, если атака» — страж и исполнитель в одном контексте, injection обходит оба; передавать логи в LLM для поиска — injection из логов и утечка секретов.
|
||
**Последствия:** два LLM-вызова на запрос (латентность, токены → квота 30/сутки); даже при обходе стража вред ограничен схемой `LogQuery`.
|
||
|
||
## ADR-014: Связь «прогон ↔ коммит» — через куб `meta`, печатающий sha
|
||
**Дата:** 2026-10-14 · **Статус:** принято (уточняет ADR-003)
|
||
|
||
**Контекст:** бот должен понять, какой прогон CI относится к его пушу. В API SourceCraft у прогона нет sha коммита (ни в списке, ни в деталях), фильтра по коммиту нет; логи служебных кубов (`#git-clone`) API не отдаёт.
|
||
**Решение:** первый куб задачи `check` в `tgit-uploads` — `meta`: `echo "TGIT_COMMIT_SHA=$SOURCECRAFT_COMMIT_SHA"`. Бот читает его лог (`SourceCraftClient.run_commit_sha`) и сопоставляет прогон с пушем.
|
||
**Альтернативы:** запускать CI из бота вручную (`POST /cicd/runs` с `head.commit`) — номер прогона известен сразу, но push-триггер пришлось бы убрать, и ручные пуши в репозиторий остались бы без CI; сопоставление по времени — ломается при параллельных пушах.
|
||
**Последствия:** +1 короткий куб на прогон; куб `meta` нельзя удалять или переименовывать (он в `.sourcecraft/`, который бот защищает от правок); прогоны старше этого решения sha не имеют.
|
||
|
||
## ADR-015: CI/CD бота — на Gitea Actions (как в Vobot)
|
||
**Дата:** 2026-10-16 · **Статус:** принято (меняет ADR-010 в части CI/CD бота)
|
||
|
||
**Контекст:** SourceCraft отложен: ключ бота там не принимается, триггеры зависят от основной ветки, раннеры с очередью до минут. Гейт и деплой нужны сейчас.
|
||
**Решение:** CI (`.gitea/workflows/ci.yml`: ruff, mypy, миграции на Postgres, pytest) и CD (`.gitea/workflows/deploy.yml` → `infra/deploy/deploy.sh`) — на Gitea Actions, как у Vobot и meditron, на общих раннерах Gitea. Деплой — на хост Vobot, `dev`/`prod` по веткам, через пользователя `tgit-deploy`. Репозиторий загрузок на сервере — временно `HSE/tgit-uploads` в Gitea.
|
||
**Альтернативы:** ждать SourceCraft — блокирует проверку и демо.
|
||
**Последствия:** `.sourcecraft/ci.yaml` бота остаётся для будущего возврата; при возврате к SourceCraft меняются `GIT_UPLOADS_URL` и ключ, код не трогаем. На RF-сервере Telegram — только через прокси (`TELEGRAM_PROXY_URL`).
|
||
|
||
## ADR-016: CI репозитория загрузок — Gitea Actions; CD — бот после зелёного CI
|
||
**Дата:** 2026-10-19 · **Статус:** принято (меняет ADR-003/014 в части репозитория загрузок; SourceCraft — при возврате)
|
||
|
||
**Контекст:** после ADR-015 репозиторий загрузок живёт в Gitea (`HSE/tgit-uploads`), а CI у него не было: бот
|
||
разворачивал сервис сразу после push, проверив только синтаксис, и «⏳ CI» в сообщении не обновлялся (#11). SourceCraft
|
||
по-прежнему не принимает ключ бота (#7).
|
||
**Решение:** CI загрузок — Gitea Actions (`.gitea/workflows/check.yml`: compile · ruff · bandit · pytest). Бот
|
||
опрашивает Gitea API (`/actions/runs?head_sha=…`) — у прогона есть sha, куб `meta` не нужен. CD остаётся у бота: после
|
||
зелёного `check` он отправляет изменённые сервисы в `tgit-runner`, как в #13. Наблюдатель зависит от протокола
|
||
`CIProvider`, вендорский код — в `app/pipeline/gitea.py`.
|
||
**Альтернативы:** SourceCraft — блокирован ключом бота; деплой из самого workflow (ssh на хост из раннера Gitea) — ключ
|
||
к `tgit-runner` пришлось бы отдать общим раннерам Gitea, а бот всё равно должен знать исход деплоя для автора и алертов;
|
||
не ждать CI — ruff/bandit/тесты из CI ничего бы не решали.
|
||
**Последствия:** боту нужен токен с чтением Actions репозитория загрузок (`CI_TOKEN`); без `CI_PROVIDER` поведение
|
||
прежнее (деплой сразу). Деплой ждёт CI: на общих раннерах это от ~30 с до минут при очереди, таймаут — 30 мин.
|
||
Возврат к SourceCraft — вторая реализация `CIProvider` поверх готового `SourceCraftClient` (ADR-014), код наблюдателя
|
||
не меняется.
|
||
|
||
## ADR-017: Демо-QR — многоразовое приглашение с лимитом для показа
|
||
**Дата:** 2026-10-22 · **Статус:** принято (дополняет ADR-011)
|
||
|
||
**Контекст:** на защите зал должен попробовать бота по одному QR с экрана; одноразовое приглашение (ADR-011) пускает
|
||
только первого (#55).
|
||
**Решение:** у приглашения — лимит входов `max_uses` (обычное — 1) и счётчик `uses`; захват атомарный
|
||
(`UPDATE … WHERE uses < max_uses`). Админ выпускает демо-QR отдельной кнопкой: `DEMO_INVITE_MAX_USES` входов (50),
|
||
`DEMO_INVITE_TTL_HOURS` (3 ч). Вошедшие помечаются `users.guest`; «Отключить гостей демо» отзывает их всех и гасит
|
||
действующие демо-QR, чтобы фото QR больше никого не пускало.
|
||
**Альтернативы:** выпустить 50 одноразовых QR — неудобно показывать; роль «гость» без коммита — больше работы, а
|
||
код гостей и так исполняется только в песочнице `tgit-runner` (ADR-005/006).
|
||
**Последствия:** риск ADR-011 «одна утечка открывает доступ всем» ограничен лимитом, коротким сроком и массовым
|
||
отзывом. Гости получают алерты и дневной лимит агента наравне с участниками.
|