- prefilter: правила из config/agent_rules.yaml (длина, команды, shell,
секреты и системные файлы, prompt injection) — без LLM
- guard: отдельный вызов, JSON {verdict, reason}; невалидный ответ = attack,
недоступный LLM — «попробуйте позже», а не атака
- planner: только AgentLogQuery (контейнер из белого списка, ≤ 7 д, шаблон
≤ 200 без вложенных квантификаторов, лимит ≤ 500); логи в LLM не идут
- бот: «🤖 Спросить агента» → показывает построенный grep и строки;
атака — вежливый отказ и 🛡 администраторам; квота 30 в сутки
- журнал agent_requests + log_queries (mode=agent)
- при LLM_PROVIDER=mock агент выключен
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 KiB
Архитектура tgit
Требования — в
SPEC.md(v2), решения и их обоснования — вDECISIONS.md.
1. Общая схема
Telegram ◄──long polling──┐
│
┌────────────┴──────────────────────────────── хост (VPS / ноутбук) ───────────┐
│ tgit-bot (один процесс, asyncio) │
│ ├─ bot/ меню, FSM, роутеры разделов, middleware доступа │
│ ├─ access/ приглашения (QR), участники, антиперебор │
│ ├─ repo/ дерево репозитория, черновики, Publisher (git + SSH) ────────┼──► SourceCraft
│ ├─ pipeline/ PipelineWatcher: CI и CD по sha, уведомления автору ◄────────┼─── REST API
│ ├─ runtime/ состояние контейнеров, проверки, логи │
│ ├─ logs/ grep-движок (Python, без shell) │
│ ├─ agent/ фильтр → LLM-страж → LLM → LogQuery ─────────────────────────┼──► LLM API
│ └─ alerts/ детекторы, антиспам, рассылка всем, восстановление │
│ │ │ │
│ ┌────┴─────┐ ┌────────┴────────┐ │
│ │ postgres │ │ docker-proxy │ (только GET: containers/logs/ │
│ └──────────┘ │ │ events/stats) │
│ └────────┬────────┘ │
│ │ /var/run/docker.sock │
│ tgit-runner (скрипт на хосте) ─┤ │
│ ▲ ssh, forced command ▼ │
│ │ tgit-svc-<service> контейнеры (песочница) │
└─────┼──────────────────────────────────────────────────────────────────────── ┘
│
SourceCraft CI (tgit-uploads): check → deploy ──┘
Репозитории:
| Репозиторий | Что внутри | CI/CD |
|---|---|---|
tgit (Gitea → зеркало в SourceCraft) |
код бота | push в любую ветку: lint + types + tests; push main: + деплой бота на хост |
tgit-uploads (SourceCraft) |
общий репозиторий команды: services/<name>/… и любые файлы |
push main: check → при успехе deploy изменённых сервисов через tgit-runner |
2. Компоненты
2.1 app/bot — интерфейс Telegram
aiogram 3. Главное меню — reply-клавиатура (📝 Коммит · 🩺 Статус · 📜 Логи · 🚨 Алерты · 👥 Участники для админа); внутри разделов — инлайн-кнопки, сообщение раздела редактируется на месте.
- Middleware доступа — первым в цепочке, на
messageиcallback_query: пускает только участников; исключение —/start <token>. Неавторизованным — одна фраза раз в сутки, остальное игнорируется. - FSM —
aiogramstorage поверх Postgres (своя реализацияBaseStorage, таблицаfsm_state) → состояние переживает рестарт. - callback_data ≤ 64 байт — в кнопки не кладём пути:
CallbackDataс коротким id, а соответствие id → путь хранится в данных FSM текущего экрана.
2.2 app/access — приглашения и участники
create_invite(admin) -> (link, png):secrets.token_urlsafe(16)→ в БДsha256(token),expires_at,created_by; QR — библиотекаsegno(PNG в памяти).redeem(token, tg_user): хэш → поиск → не истёк, не использован → в транзакцииused_by/used_at+ создание участника.- Антиперебор: счётчик неудачных попыток по
tg_idза час (таблицаaccess_attempts). revoke(user):is_active = false, удаление черновика, сброс FSM.
2.3 app/repo — дерево, черновики, пуш
- Локальный клон
tgit-uploadsв volume. Перед открытием браузера —git fetch(не чаще раза в 10 с); дерево —git ls-tree origin/main <path>, содержимое —git show origin/main:<path>. Быстро и без API. - Черновик (
drafts+draft_changes):base_sha(HEAD при начале), список измененийadd|modify|deleteс содержимым. Виртуальные новые папки живут только в FSM, пока в них не добавлен файл. - Валидация (
repo/validate.py): имя файла, размер, UTF-8, NUL,ast.parseдля.py, запрет.sourcecraft/и служебных файлов. - Publisher — единственная очередь (
asyncio.Queue, один воркер), чтобы пуши не конфликтовали:fetch→ проверка конфликта (git diff --name-only base_sha origin/main∩ пути черновика) →reset --hard origin/main→ применить изменения →commitс трейлеромTelegram-User→push; отказ push →pull --rebase(до 3 раз). git — черезasyncio.create_subprocess_exec(аргументы списком),GIT_SSH_COMMANDс ключом бота.
2.4 app/sourcecraft — клиент API
httpx.AsyncClient, Bearer-токен, ретраи на сетевые ошибки и 5xx (экспоненциально, до 5 попыток),
pydantic-модели ответов. Методы: list_runs, get_run, get_cube_logs (постранично), run_commit_sha — sha из лога куба check/meta (у прогона в API нет коммита, ADR-014). Подробности — dev/SOURCECRAFT_API.md.
2.5 app/pipeline — наблюдатель CI/CD
Фоновая задача. Пока есть незавершённые пуши — опрашивает /cicd/runs (10 с, бэкофф до 60 с), сопоставляет прогон с
пушем по sha (из куба meta, ADR-014). Отслеживает задачи check (CI) и deploy (CD) workflow check-and-deploy отдельно; на смене статуса — редактирует сообщение
о пуше и шлёт автору уведомление (успех / падение + лог упавшего куба). Падение deploy → событие для alerts.
Таймаут — 30 мин. Webhooks у SourceCraft нет (ADR-003).
2.6 app/runtime — контейнеры и проверки
Через docker-socket-proxy (только GET):
- Список — контейнеры с меткой
tgit.managed=true(сервисы и служебные: bot, postgres, proxy). - Карточка —
inspect(state, health, restart count, started_at, меткиtgit.sha,tgit.deployed_at) +stats. - Проверки (
runtime/checks.py) — набор независимых функцийCheck -> CheckResult(ok, detail):running,docker_health,restarts_stable(сравнение с 5 мин назад, история из БД),cpu_ok,mem_ok,no_errors_in_logs(5 мин, шаблон изconfig/checks.yaml),http_probe(если у контейнера есть меткаtgit.health_url; пока нет runner (#13) — проба из сети бота). Каждая проверка — со своим таймаутом: упавшая или зависшая даёт ❌ с причиной, остальные выполняются. Пороги и окно — вconfig/checks.yaml. - Изоляция — прокси отдаёт контейнеры всего хоста, поэтому бот сам фильтрует по метке
tgit.managed=trueи отказывает вinspect/stats/logsпо id не из этого списка ещё до запроса. - Мониторинг — поток
events(die,oom,health_status,restart) и опросstatsраз в 15 с → события дляalerts.
2.7 app/logs — grep-движок
parse_grep(expr) -> GrepQuery— подмножество синтаксиса grep: шаблон,-i,-v,-E,-c,-C N; всё остальное — ошибка с подсказкой. Разборshlex.split, никакого исполнения.run(query, source)— источникruntime.logs(container, since, tail); поиск модулемregexс таймаутом на строку; лимиты: шаблон ≤ 200, вход ≤ 5 МБ, вывод ≤ 500 строк.to_command(query)— человекочитаемыйgrep …для показа пользователю.
2.8 app/agent — LLM-агент для логов
промт ─► prefilter ─✗─► отказ + алерт 🛡
│✓
▼
guard (LLM, JSON {verdict, reason}) ─ attack / невалидный JSON ─► отказ + алерт 🛡
│safe
▼
planner (LLM, JSON LogQuery: container, since, pattern, flags, limit) ─ невалидно ─► «не смог построить запрос»
│
▼
validate (pydantic, белый список контейнеров, компиляция regex, анализ бэктрекинга)
│
▼
logs.run(query) ─► ответ: grep-команда + строки
prefilter.py— детерминированные правила изconfig/agent_rules.yaml(длина, шаблоны атак).guard.pyиplanner.py— отдельные вызовы с узкими системными промптами (config/prompts/); в planner передаются только промт пользователя и список контейнеров — логи в LLM не попадают (ADR-007, ADR-013).- Провайдер —
LLMProvider+ фабрика (ADR-008); в тестахmock. - Журнал:
agent_requests(промт, вердикт, запрос, токены); выполненный поиск — ещё и вlog_queries(mode=agent). - Квота — 30 вопросов на участника за скользящие сутки, считается по
agent_requests. - Недоступный LLM — «попробуйте позже», а не атака; невалидный ответ стража — атака; невалидный ответ планировщика — «не смог построить запрос».
- Алерт 🛡 пока отправляется администраторам напрямую; с #15 он станет алертом
agent_attackв общем журнале. - При
LLM_PROVIDER=mockагент выключен: mock отвечает стражу{}, и каждый вопрос считался бы атакой.
2.9 app/alerts — алерты
- Детекторы превращают события runtime/pipeline/хоста в
AlertEvent(kind, service, payload); пороги —config/alerts.yaml. - Дедупликация: ключ
kind + service; открытый алерт с тем же ключом →count += 1, исходные сообщения редактируются (не чаще раза в 10 мин), новые не шлются. - Рассылка всем активным участникам (кроме 🛡 — только администраторам) с учётом лимита Telegram (~30 msg/s,
очередь отправки);
alert_deliveriesхранитchat_id/message_id— чтобы обновлять счётчик и отвечать «✅ восстановлено». - Восстановление: детектор сообщает, что условие прошло →
resolved_at, ответ на исходные сообщения. - Список: активные + история 7 дней, пагинация.
2.10 app/db — хранилище
SQLAlchemy 2 (async) + Alembic. PostgreSQL в Compose; в тестах — SQLite.
| Таблица | Поля (основные) |
|---|---|
users |
tg_id PK, username, full_name, role (member/admin), is_active, invited_by, joined_at, last_seen_at |
invites |
id, token_hash UNIQUE, created_by, created_at, expires_at, used_by, used_at |
access_attempts |
tg_id, at, ok |
fsm_state |
key PK, state, data JSON |
drafts |
id, user_id UNIQUE, base_sha, message, created_at, expires_at |
draft_changes |
id, draft_id, path, op (add/modify/delete), content BYTEA |
pushes |
id, user_id, sha, message, paths JSON, chat_id, message_id, ci_status, cd_status, created_at |
pipeline_runs |
id, push_id, stage (ci/cd — задачи одного workflow check-and-deploy), run_slug, status, failed_cube, url, updated_at |
container_snapshots |
container, at, restart_count, cpu_pct, mem_pct, health — для проверок «за 5 мин» (TTL 1 сутки) |
alerts |
id, kind, service, payload JSON, count, opened_at, last_at, resolved_at |
alert_deliveries |
alert_id, chat_id, message_id |
log_queries |
id, user_id, container, mode (tail/grep/agent), query JSON, lines, at |
agent_requests |
id, user_id, prompt, verdict, reason, log_query JSON, tokens, at |
2.11 Конфигурация
app/config.py — pydantic-settings, класс на подсистему (TelegramSettings, AccessSettings, SourceCraftSettings,
GitSettings, DockerSettings, LLMSettings, AlertSettings), всё из .env. Пороги, тексты, правила, промпты — config/*.yaml.
3. Потоки данных
3.1 Вход по приглашению
админ ─ «Пригласить» ─► access.create_invite ─► QR (PNG) + ссылка
новый пользователь ─ скан QR ─► t.me/<bot>?start=<token> ─► /start <token> ─► redeem ─✓─► users + главное меню
└✗─► отказ (+ счётчик попыток)
3.2 Коммит → CI → CD
📝 Коммит ─► дерево origin/main ─► файл/папка ─► изменения в черновик ─► 💬 сообщение ─► 🚀 Push ─► подтверждение
│
Publisher (очередь) ─ fetch, конфликт?, commit, push ──► tgit-uploads@main
│ SourceCraft CI
«a1b2c3d ⏳ CI» ◄──────────────────────────────────────────────────────────────────────────┘ check: py_compile · ruff · pytest · bandit
PipelineWatcher ─ poll runs(sha) ─► «✅ CI» ─► «⏳ CD» ─► «🚀 CD: weather» deploy: ssh → tgit-runner deploy <svc> <sha>
└► ❌ + лог шага автору; ❌ CD → алерт всем
3.3 Логи через агента
См. §2.8; ответ — grep-команда + найденные строки; запись в agent_requests и log_queries.
3.4 Алерт
runtime/pipeline/хост → детектор → дедупликация → рассылка всем участникам → … → условие прошло → «✅ восстановлено».
4. Песочница сервисов (tgit-runner)
infra/runner/tgit-runner — bash-скрипт на хосте; единственная команда, доступная ключам CI и бота (command="…" в
authorized_keys пользователя tgit-deploy). Подкоманды: deploy <service> <sha>, restart <service>,
probe <service> <path> (HTTP-проба изнутри сети сервиса). Аргументы валидируются: имя ^[a-z][a-z0-9-]{1,30}$, sha — hex.
deploy: забирает архив services/<service> по sha из tgit-uploads → собирает образ tgit-svc-<service>:<sha> из
шаблона (python:3.12-slim + requirements.txt, HEALTHCHECK из tgit.yaml сервиса, если задан) → запускает:
docker run -d --name tgit-svc-<service> --label tgit.managed=true --label tgit.service=<service>
--label tgit.sha=<sha> --label tgit.deployed_at=<iso>
--user 10001:10001 --read-only --tmpfs /tmp:size=16m
--cap-drop ALL --security-opt no-new-privileges
--memory 128m --memory-swap 128m --cpus 0.25 --pids-limit 64
--network none # или tgit-svc-egress, если сеть разрешена администратором
--restart on-failure:3
--log-opt max-size=5m --log-opt max-file=2
Если новый контейнер не стал healthy за 60 с — откат на предыдущий образ и ненулевой код выхода (CD падает → алерт ⛔).
5. CI/CD
5.1 Репозиторий бота (tgit/.sourcecraft/ci.yaml)
Код живёт в Gitea, SourceCraft получает его push-зеркалом и запускает CI/CD (ADR-010).
| Триггер | Workflow | Шаги |
|---|---|---|
| push в любую ветку (через зеркало Gitea; PR живут в Gitea — ADR-010) | check |
uv sync --frozen → ruff check + ruff format --check → mypy app → pytest --cov=app --cov-fail-under=70 |
push в main |
check + deploy |
→ infra/deploy/deploy.sh по SSH (секреты DEPLOY_SSH_KEY, DEPLOY_HOST) → smoke: healthcheck контейнера |
5.2 Репозиторий загрузок (tgit-uploads/.sourcecraft/ci.yaml)
Шаблон хранится в tgit/infra/uploads-template/ и ставится в tgit-uploads один раз.
| Workflow | Шаги |
|---|---|
check |
изменённые файлы (git diff --name-only HEAD~1) → py_compile · ruff check (мягкий набор правил) · pytest для затронутых сервисов · bandit |
deploy (после check) |
изменённые services/<name>/ → ssh tgit-deploy@host deploy <name> <sha> для каждого |
6. Деплой
| Где | Как |
|---|---|
| Любая машина | git clone → cp .env.example .env → заполнить → make up |
| VPS vobot (основной) | push в main → CI → infra/deploy/deploy.sh → /srv/tgit (пользователь tgit-deploy, Compose-проект tgit) |
deploy.sh (по образцу meditron): git archive → scp → распаковка в app.new → перенос .env → атомарная замена
app → docker compose -p tgit up -d --build → ожидание healthcheck → запись REVISION. Все SSH-шаги с ретраями
(хост режет часть соединений). Хост задаётся DEPLOY_HOST — тот же скрипт работает для любого сервера.
Бюджет ресурсов на VPS (свободно ~1,9 ГБ RAM): бот ≤ 256 МБ, postgres ≤ 256 МБ, proxy ≤ 32 МБ, сервисы ≤ 10 × 128 МБ.
7. Структура репозитория
app/ код бота: bot, access, repo, sourcecraft, pipeline, runtime, logs, agent, alerts, db, config.py, main.py
config/ alerts.yaml, checks.yaml, agent_rules.yaml, texts.yaml, prompts/
infra/
deploy/ deploy.sh, docker-compose.server.yml
runner/ tgit-runner, шаблон Dockerfile сервиса, authorized_keys.example
uploads-template/ .sourcecraft/ci.yaml, ruff.toml, README для tgit-uploads
examples/ демо-сервисы: ok, syntax error, lint fail, crash, cpu hog, unhealthy, escape attempts
tests/ pytest: unit + интеграционные с фейками
docs/ SPEC, ARCHITECTURE, DECISIONS, dev/, презентация
Dockerfile · docker-compose.yml · Makefile · pyproject.toml · .env.example
8. Что важно учесть
| Риск | Что делаем |
|---|---|
| Случайные люди и спам | Только одноразовые приглашения с TTL; middleware на каждом апдейте; антиперебор токенов; лимиты пушей и запросов |
| Утечка QR | Одноразовость + 24 ч; админ видит, кто вошёл по какому приглашению, и может отозвать доступ |
| Участник ломает CI из бота | Запрет правок .sourcecraft/ и служебных файлов (C-16) |
| Чужой код на одном сервере с прод-Vobot | Песочница §4, лимиты, сеть выключена; общий лимит 10 сервисов; в перспективе — отдельный хост (ADR-005) |
| Docker-сокет = root на хосте | Бот — только через read-only proxy; запуск — через tgit-runner с forced command |
Пользователь tgit-deploy в группе docker ≈ root |
Ключ ограничен одной командой; tgit-runner валидирует аргументы |
| Атака через агента (injection, exfiltration) | Фильтр → LLM-страж → структурированный LogQuery → валидация; логи в LLM не передаются; алерт админам |
| ReDoS в regex (свой grep и от LLM) | Модуль regex с таймаутом, лимит длины шаблона и объёма логов |
| Утечка секретов | Секреты не логируются и не идут в LLM; маскирование в выводе P2 |
| Нестабильный SourceCraft API | Ретраи, идемпотентность, проверка перед повторным POST |
| Гонки пушей и конфликты | Одна очередь Publisher; проверка изменений с base_sha; pull --rebase при отказе |
| Лимиты Telegram (4096 символов, 64 байта callback_data, ~30 msg/s) | Длинное — файлом; короткие id в кнопках; очередь рассылки алертов |
| Шторм алертов | Дедупликация kind+service, счётчик вместо новых сообщений, «восстановлено» ответом |
| Диск хоста занят на 75 % | Ротация логов контейнеров, docker image prune старых ревизий в tgit-runner, алерт 💾 |