Files
tgit/docs/DECISIONS.md
ElebobaandClaude Opus 5.5 e8c3a321a3
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
feat(access): демо-QR — многоразовое приглашение для показа
- 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>
2026-10-22 18:11:53 +03:00

172 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектурные решения (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 «одна утечка открывает доступ всем» ограничен лимитом, коротким сроком и массовым
отзывом. Гости получают алерты и дневной лимит агента наравне с участниками.