diff --git a/.env.example b/.env.example index 4d8eb43..37c887f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather ADMIN_IDS= # Telegram ID первых администраторов через запятую LOG_LEVEL=INFO +TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую # --- База данных --- POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..d3c9f5f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: CD + +# Как в Vobot: тесты здесь не гоняем — они прошли в CI на PR. +# dev → /srv/tgit/dev, main → /srv/tgit/prod на хосте Vobot (infra/deploy/deploy.sh). +# Окружение без /srv/tgit//.env на сервере пропускается (prod — когда будет свой токен бота). +on: + push: + branches: [dev, main] + workflow_dispatch: {} + +# Деплои одной ветки — строго по очереди, без отмены. +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy: + name: deploy → ${{ github.ref_name == 'main' && 'prod' || 'dev' }} + runs-on: ubuntu-latest + env: + DEPLOY_ENV: ${{ github.ref_name == 'main' && 'prod' || 'dev' }} + steps: + - uses: actions/checkout@v4 + + # Серия мержей подряд: устаревший прогон сам сворачивается, задеплоит актуальный (опыт Vobot) + - id: guard + name: "Актуален ли ещё этот пуш?" + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "go=true" >> "$GITHUB_OUTPUT"; exit 0; fi + REMOTE_SHA=$(git ls-remote origin "refs/heads/${{ github.ref_name }}" | cut -f1) + if [ "$REMOTE_SHA" = "${{ github.sha }}" ]; then echo "go=true" >> "$GITHUB_OUTPUT" + else echo "⏭ ${{ github.sha }} уже не HEAD ${{ github.ref_name }} — пропускаю"; echo "go=false" >> "$GITHUB_OUTPUT"; fi + + - name: Deploy + if: steps.guard.outputs.go == 'true' + env: + # Через env:, а не инлайном в run: — многострочный ключ иначе может попасть в лог (опыт Vobot) + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + export DEPLOY_SSH_OPTS="-F /dev/null -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20" + infra/deploy/deploy.sh "$DEPLOY_ENV" HEAD diff --git a/CLAUDE.md b/CLAUDE.md index 507bbe8..3faeb4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ Telegram-бот «git в телеграме» для команды (учебн | [`docs/dev/ISSUE_TEMPLATE.md`](docs/dev/ISSUE_TEMPLATE.md) | Шаблон тела issue | | [`docs/dev/GITEA_API.md`](docs/dev/GITEA_API.md) | Gitea API: issue, PR, зависимости, зеркало | | [`docs/dev/SOURCECRAFT_API.md`](docs/dev/SOURCECRAFT_API.md) | SourceCraft API: CI-прогоны, логи, секреты | +| [`docs/dev/DEPLOY.md`](docs/dev/DEPLOY.md) | **Деплой:** сервер, окружения dev/prod, руками, откат | --- diff --git a/app/config.py b/app/config.py index 689f438..15f52b4 100644 --- a/app/config.py +++ b/app/config.py @@ -13,6 +13,8 @@ class TelegramSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_") bot_token: SecretStr + # http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron) + proxy_url: SecretStr | None = None class AccessSettings(BaseSettings): diff --git a/app/main.py b/app/main.py index 789e390..4b47d8c 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ import contextlib import logging from aiogram import Bot, Dispatcher +from aiogram.client.session.aiohttp import AiohttpSession from app import __version__ from app.access.service import AccessService @@ -54,8 +55,15 @@ async def run(settings: Settings) -> None: asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)), asyncio.create_task(cleanup_loop(sessions)), ] - bot = Bot(settings.telegram.bot_token.get_secret_value()) - log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids)) + proxy = settings.telegram.proxy_url + session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None + bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session) + log.info( + "tgit %s starting, %d bootstrap admin(s), telegram %s", + __version__, + len(settings.access.admin_ids), + "via proxy" if proxy else "direct", + ) try: await dp.start_polling(bot, handle_signals=True) finally: diff --git a/docker-compose.yml b/docker-compose.yml index 3639ecc..f4158fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: postgres: condition: service_healthy restart: unless-stopped - mem_limit: 256m + mem_limit: 384m # ~190 MB idle; git clone/push and aiogram spikes need headroom labels: tgit.managed: "true" logging: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4e0b4f0..86136de 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -129,3 +129,11 @@ **Решение:** первый куб задачи `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`). diff --git a/docs/dev/DEPLOY.md b/docs/dev/DEPLOY.md new file mode 100644 index 0000000..cec1b90 --- /dev/null +++ b/docs/dev/DEPLOY.md @@ -0,0 +1,45 @@ +# Деплой бота + +CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` → окружение **dev**, push в `main` → **prod**. +Под капотом — `infra/deploy/deploy.sh`, тот же скрипт работает руками и для любого Docker-хоста. + +## Сервер (хост Vobot, 72.56.247.166) + +| Что | Где | +|---|---| +| Пользователь | `tgit-deploy` (группа `docker`), ключ CI в `~/.ssh/authorized_keys` | +| Окружение | `/srv/tgit//` — `.env` (только здесь, 600), `app/` (текущий релиз), `app.old/` (предыдущий), `REVISION` | +| Контейнеры | Compose-проект `tgit-`: `tgit--bot-1`, `tgit--postgres-1`; volumes `pgdata`, `uploads` | +| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) | +| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью | + +Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`). + +## Руками + +```bash +DEPLOY_SSH_OPTS="-F /dev/null -i ~/.ssh/tgit_ci_deploy -o IdentitiesOnly=yes" infra/deploy/deploy.sh dev HEAD +``` + +Другой сервер: `DEPLOY_HOST=user@host`, на нём — Docker, пользователь в группе `docker`, `/srv/tgit//.env`. + +## Проверить + +```bash +ssh tgit-deploy@72.56.247.166 'cat /srv/tgit/dev/REVISION; docker ps --filter name=tgit-dev; docker logs --tail 20 tgit-dev-bot-1' +``` + +## Откат + +Ошибка health при деплое печатает команду; руками: + +```bash +ssh tgit-deploy@72.56.247.166 +cd /srv/tgit/dev && rm -rf app && mv app.old app && cd app && docker compose -p tgit-dev up -d --build +``` + +## Завести prod + +Нужен **отдельный токен бота** (один токен — один процесс polling): создать бота у @BotFather → +`/srv/tgit/prod/.env` по образцу dev (`TELEGRAM_BOT_TOKEN`, `ADMIN_IDS`, `POSTGRES_PASSWORD`, `GIT_UPLOADS_URL`, +`GIT_SSH_KEY_B64`, `TELEGRAM_PROXY_URL`, `LLM_PROVIDER`). Пока файла нет, деплой `main` пропускается. diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh new file mode 100755 index 0000000..a79c021 --- /dev/null +++ b/infra/deploy/deploy.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Deploy a git ref of the bot to a server. Used by hand and by CD (.gitea/workflows/deploy.yml). +# infra/deploy/deploy.sh [git-ref] default ref: HEAD +# Env: DEPLOY_HOST (default tgit-deploy@72.56.247.166 — the vobot host; any Docker host works) +# DEPLOY_SSH_OPTS (extra ssh/scp options, e.g. -i key) +# Server layout: /srv/tgit//{.env, app/, app.old/, REVISION}; secrets live only in /srv/tgit//.env. +# The host drops some ssh connections — every remote step is retried, and each one is safe to repeat. +set -euo pipefail +ENV=${1:?usage: deploy.sh [ref]} +REF=${2:-HEAD} +case "$ENV" in dev | prod) ;; *) echo "env must be dev or prod" >&2; exit 2 ;; esac +HOST=${DEPLOY_HOST:-tgit-deploy@72.56.247.166} +OPTS=${DEPLOY_SSH_OPTS:-} +DIR=/srv/tgit/$ENV +SHA=$(git rev-parse --short "$REF") +TGZ=$(mktemp --suffix=.tgz) +trap 'rm -f "$TGZ"' EXIT +git archive --format=tar.gz -o "$TGZ" "$REF" + +retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo "retry $n: $1" >&2; sleep $((n * 5)); done; } + +# shellcheck disable=SC2086 +if ! retry ssh $OPTS "$HOST" "test -f $DIR/.env"; then + echo "::notice::$HOST:$DIR/.env not found — environment '$ENV' is not configured, skipping deploy" + exit 0 +fi + +echo "deploy $REF ($SHA) -> $HOST:$DIR" +# shellcheck disable=SC2086 +retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release-$SHA.tgz" +# shellcheck disable=SC2086 +retry scp -q $OPTS infra/deploy/remote.sh "$HOST:$DIR/remote.sh" +# shellcheck disable=SC2086 +retry ssh $OPTS "$HOST" "bash $DIR/remote.sh $ENV $SHA" diff --git a/infra/deploy/remote.sh b/infra/deploy/remote.sh new file mode 100755 index 0000000..a715110 --- /dev/null +++ b/infra/deploy/remote.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Runs on the server: unpack release-.tgz, swap app/ atomically, compose up, wait for healthy. +# bash /srv/tgit//remote.sh (uploaded by deploy.sh; safe to re-run) +set -euo pipefail +ENV=$1 +SHA=$2 +DIR=/srv/tgit/$ENV +PROJECT=tgit-$ENV +cd "$DIR" + +if [ -f "release-$SHA.tgz" ]; then + rm -rf app.new && mkdir app.new && tar -xzf "release-$SHA.tgz" -C app.new && rm -f "release-$SHA.tgz" + cp .env app.new/.env + rm -rf app.old + [ -d app ] && mv app app.old + mv app.new app +fi + +cd app +docker compose -p "$PROJECT" up -d --build --remove-orphans /dev/null || true) + [ "$status" = healthy ] && break + sleep 3 +done +if [ "$status" != healthy ]; then + echo "bot is '$status' after 2 min — last logs:" >&2 + docker logs --tail 50 "$PROJECT-bot-1" >&2 || true + echo "rollback: cd $DIR && rm -rf app && mv app.old app && cd app && docker compose -p $PROJECT up -d --build" >&2 + exit 1 +fi +echo "$SHA" > ../REVISION +docker image prune -f >/dev/null +echo "bot healthy @ $SHA ($ENV)" diff --git a/pyproject.toml b/pyproject.toml index dd0ca9b..10243bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyyaml>=6.0", "regex>=2024.7", "segno>=1.6", + "aiohttp-socks>=0.9", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 9f9f9aa..036ecf3 100644 --- a/uv.lock +++ b/uv.lock @@ -74,6 +74,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] +[[package]] +name = "aiohttp-socks" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/1d/a306e0111222180e60f17131a3f5d9bc694dd999a8115959a7dd76c2238e/aiohttp_socks-0.12.0.tar.gz", hash = "sha256:3caf9f5a4164611122d412bc11b2f9114fd29c85e1ba27bb38060d3c236bdc8d", size = 12061, upload-time = "2026-08-12T04:43:15.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/64/ca6289632020523ea1841f01363f83ada10eeb0f21dd931fc1118dd85668/aiohttp_socks-0.12.0-py3-none-any.whl", hash = "sha256:ba6f95ec775c761d87f8578ab48f137d0457c676da104984202bf75e747d5ee6", size = 10693, upload-time = "2026-08-12T04:43:14.524Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -642,6 +655,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] +[[package]] +name = "python-socks" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -763,6 +785,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiogram" }, + { name = "aiohttp-socks" }, { name = "alembic" }, { name = "asyncpg" }, { name = "httpx" }, @@ -789,6 +812,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.13" }, + { name = "aiohttp-socks", specifier = ">=0.9" }, { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", specifier = ">=0.29" }, { name = "httpx", specifier = ">=0.27" },