diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..546c45d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.gitignore +.gitattributes + +# venv / кеши +.venv +.venv11 +venv +env +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.ruff_cache +.mypy_cache + +# IDE / локальное +.idea +.vscode +AGENTS.md +checklist.md +*.log + +# инфраструктура и документация (в образ не нужны) +deploy +.gitea +tests +docs +*.md +Dockerfile +docker-compose*.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4e640b7 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [master] + tags: ["v*"] + workflow_dispatch: + +env: + REGISTRY: git.mpakosrv.ru + IMAGE_NAME: mpakobecx/dispelevilbot-bot + +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Установка зависимостей + run: pip install -r requirements-dev.txt + # Линт/формат пока не блокируют пайплайн (см. TODO). После первого прогона + # выполните локально `ruff check --fix .` и `black .`, затем уберите + # continue-on-error, чтобы стиль стал обязательным. + - name: Ruff + continue-on-error: true + run: ruff check . + - name: Black + continue-on-error: true + run: black --check . + - name: Pytest + run: pytest -q + + build-push: + runs-on: ubuntu-latest + needs: lint-test + # Собираем и пушим образ. Автодеплой НЕ делаем — выкатка вручную (test -> prod). + steps: + - uses: actions/checkout@v4 + - name: Логин в Gitea Registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Сборка и публикация образа + run: | + set -e + IMAGE="$REGISTRY/$IMAGE_NAME" + SHORT_SHA="${GITHUB_SHA:0:12}" + TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest" + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAGS="$TAGS $IMAGE:${GITHUB_REF_NAME}" + fi + ARGS="" + for t in $TAGS; do ARGS="$ARGS -t $t"; done + docker build $ARGS . + for t in $TAGS; do docker push "$t"; done diff --git a/.gitignore b/.gitignore index c0e5d83..f498bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,13 @@ __pycache__/ .venv/ .venv11/ venv/ -env/ # Secrets / environment .env .env.* !.env.example +env/*.env +!env/*.env.example # IDE .idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd19c88 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Непривилегированный пользователь +RUN useradd --create-home --uid 1000 app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY common ./common +COPY bot ./bot +COPY images ./images + +USER app + +# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py). +# Путь должен совпадать с HEARTBEAT_PATH (по умолчанию /tmp/dispelevilbot.heartbeat). +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD python -c "import os,sys,time; p='/tmp/dispelevilbot.heartbeat'; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p) < 120 else 1)" + +CMD ["python", "-m", "bot"] diff --git a/README.md b/README.md index add58a9..5e9ed1e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ common/ # общий пакет: config, logging, db, repositories, models, bot/ # сервис бота: handlers, services, keyboards, notifications sql/ # миграции Flyway (V__*.sql, R__*.sql) images/ # статичные картинки (приветствие) +deploy/ # материалы деплоя (Caddyfile, инструкции) +tests/ # тесты +Dockerfile # образ бота +docker-compose.yml / docker-compose.prod.yml +.gitea/ # CI (Gitea Actions) ``` ## Требования @@ -36,8 +41,26 @@ APP_ENV=prod python -m bot # prod ## База данных -Схема управляется миграциями Flyway в `sql/` — см. [sql/README.md](sql/README.md). -Накат выполняется автоматически сервисом `migrate` при Docker-развёртывании. +Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable). +При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом. + +## Docker + +Локальный запуск (postgres + migrate + bot): + +```bash +cp .env.example .env # заполнить +docker compose up --build +``` + +Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: + +```bash +docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d +docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d +``` + +Registry, перенос данных, TLS и firewall — см. [`deploy/README.md`](deploy/README.md). ## Разработка diff --git a/bot/__main__.py b/bot/__main__.py index 72f5bf6..136de45 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,6 +6,7 @@ import asyncio import logging from bot.bot_factory import create_bot, create_dispatcher +from bot.services.heartbeat import run_heartbeat from bot.services.notifications import run_notifications from common import __version__ from common.config import get_settings @@ -26,12 +27,14 @@ async def main() -> None: dp = create_dispatcher() notifications_task = asyncio.create_task(run_notifications(bot)) + heartbeat_task = asyncio.create_task(run_heartbeat()) try: await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) finally: notifications_task.cancel() - await asyncio.gather(notifications_task, return_exceptions=True) + heartbeat_task.cancel() + await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True) await close_pool() await bot.session.close() diff --git a/bot/services/heartbeat.py b/bot/services/heartbeat.py new file mode 100644 index 0000000..b198488 --- /dev/null +++ b/bot/services/heartbeat.py @@ -0,0 +1,28 @@ +"""Heartbeat для Docker healthcheck. + +Бот периодически обновляет mtime файла; healthcheck считает контейнер живым, +если файл свежий. Если event loop завис — heartbeat перестаёт обновляться. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from common.config import get_settings + +logger = logging.getLogger(__name__) + +HEARTBEAT_INTERVAL = 30 + + +async def run_heartbeat() -> None: + """Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд.""" + path = Path(get_settings().heartbeat_path) + while True: + try: + path.touch() + except OSError: + logger.exception("Не удалось обновить heartbeat-файл %s", path) + await asyncio.sleep(HEARTBEAT_INTERVAL) diff --git a/common/config.py b/common/config.py index ccce0a0..6f3514b 100644 --- a/common/config.py +++ b/common/config.py @@ -45,6 +45,9 @@ class Settings(BaseSettings): timezone: str = "Europe/Moscow" + #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). + heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" + @field_validator("admin_ids", mode="before") @classmethod def _parse_admin_ids(cls, value: object) -> object: diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..cc0b666 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,13 @@ +# Пример Caddyfile для reverse proxy + автоматического TLS. +# Нужен, когда появится админка/Mini App (Блоки 4–5). +# Caddy сам получит сертификаты Let's Encrypt (нужны открытые 80/443 и DNS на этот сервер). + +# Админка +admin.git.mpakosrv.ru { + reverse_proxy admin:8000 +} + +# Telegram Mini App +app.git.mpakosrv.ru { + reverse_proxy admin:8000 +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..60b2c82 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,87 @@ +# Деплой (Блок 2) + +## Образы и registry + +Целевой registry — **Gitea/Forgejo Container Registry** (домашний сервер). Имя образа: + +``` +/mpakobecx/dispelevilbot-bot: +``` + +Сборка и публикация вручную: + +```bash +docker login git.mpakosrv.ru -u mpakobecx # пароль — access token +docker build -t git.mpakosrv.ru/mpakobecx/dispelevilbot-bot:0.1 . +docker push git.mpakosrv.ru/mpakobecx/dispelevilbot-bot:0.1 +``` + +В CI (`.gitea/workflows/ci.yml`) это делается автоматически по пушу в `master` +(и по тегам `v*`). Нужно завести секреты репозитория `REGISTRY_USER`, `REGISTRY_TOKEN`. + +> При доступе из локальной сети registry может быть на `192.168.31.158:3000` +> (тогда добавьте его в `insecure-registries` Docker-демона либо поставьте TLS-proxy). +> С сервера, где крутится runner, удобнее доменное имя. + +## Контуры test и prod + +Один compose-файл, разные проекты и env-файлы (файл задаётся и для интерполяции, +и как `env_file` сервисов): + +```bash +# прод +REGISTRY=git.mpakosrv.ru IMAGE_TAG=0.1 \ + docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d + +# тест +REGISTRY=git.mpakosrv.ru IMAGE_TAG=0.1 \ + docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d +``` + +Имя проекта (`-p`) даёт отдельные volume'ы (свои БД у `prod` и `test`). +Перед первым запуском: `cp env/prod.env.example env/prod.env` и заполнить. + +Порядок выкатки: сначала `-p test`, проверить, затем `-p prod`. + +Откат на предыдущую версию: поднять предыдущий `IMAGE_TAG` (например, commit-sha). + +## Миграции БД + +Накатываются сервисом `migrate` (Flyway) перед ботом, автоматически. + +- Чистая БД — применяются все `V__*`/`R__*`. +- **Существующая** (непустая) БД — включён `FLYWAY_BASELINE_ON_MIGRATE=true`, Flyway + сделает baseline и применит только новые миграции. Проверьте версию baseline. + +## Перенос данных со старого Postgres (короткое окно простоя) + +1. Остановить старого бота. +2. `pg_dump -Fc -f dump.pgc old_db` +3. Развернуть новый контур, применить миграции (получится чистая целевая схема). +4. Загрузить данные: + - либо `pg_restore --data-only --disable-triggers -d new_db dump.pgc`; + - либо `pg_dump --data-only` и загрузка по таблицам. +5. Если в исходных данных есть дубли (`tg_users`/`de_users`/`unsubscribe`) — V12 убирает их + перед созданием PK; при загрузке **после** миграций дедуп нужно сделать вручную до вставки. +6. Сверить: число пользователей, `SUM(money.amount)`, число сообщений. +7. Переключить бота на новый Postgres, проверить команды, удалить старый контур. + +Бэкап старой БД — обязательно. Проверить восстановление (dry-run) до окна. + +Автобэкапы нового Postgres: `pg_dump` по cron + ротация, с выгрузкой на домашний сервер/в облако. + +## Reverse proxy и TLS (Блок 2.4) + +Пока нечего проксировать (админка/Mini App — Блоки 4–5). Когда появятся: + +- положить `deploy/Caddyfile` и раскомментировать сервис `caddy` в `docker-compose.prod.yml`; +- направить DNS `admin.*`/`app.*` на сервер, открыть 80/443; +- Caddy сам выпустит сертификаты. + +Порт, не забыть закрыть наружу всё, кроме 80/443 (Postgres и ClickHouse — только внутри). + +## Firewall (заметка по SSH-доступу) + +- SSH git у Forgejo — контейнерный порт 22 → на хосте **2222**. +- Из локальной сети: `ssh://git@192.168.31.158:2222/...`. +- Снаружи нужен проброс/открытие 2222 (и в Forgejo `[server] SSH_DOMAIN/SSH_PORT`). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..b005880 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,78 @@ +# Прод/test-контур: образы тянутся из Gitea Registry. +# +# Контуры разделяются именем compose-проекта (-p) и файлом окружения (ENV_FILE): +# ENV_FILE=env/prod.env docker compose -p prod -f docker-compose.prod.yml up -d +# ENV_FILE=env/test.env docker compose -p test -f docker-compose.prod.yml up -d +# +# Имя проекта задаёт отдельные volume'ы (pgdata) — у prod и test свои БД. +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-dispel_evil} + POSTGRES_USER: ${DB_USER:-de_admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"] + interval: 10s + timeout: 5s + retries: 10 + + migrate: + image: flyway/flyway:10 + command: ["migrate"] + environment: + FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil} + FLYWAY_USER: ${DB_USER:-de_admin} + FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD} + FLYWAY_LOCATIONS: filesystem:/flyway/sql + FLYWAY_BASELINE_ON_MIGRATE: "true" + FLYWAY_CONNECT_RETRIES: "10" + volumes: + - ./sql:/flyway/sql:ro + depends_on: + postgres: + condition: service_healthy + restart: "no" + + bot: + image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecx/dispelevilbot-bot:${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: + - ${ENV_FILE:-env/prod.env} + environment: + DB_HOST: postgres + DB_PORT: "5432" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # --- Reverse proxy и TLS (Блок 2.4) --- + # Включается, когда появится админка/Mini App (Блоки 4–5). + # Раскомментировать и положить deploy/Caddyfile: + # + # caddy: + # image: caddy:2 + # restart: unless-stopped + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + # - caddy_data:/data + # - caddy_config:/config + +volumes: + pgdata: + # caddy_data: + # caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ece4353 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# Базовый compose для локального запуска и разработки. +# Секреты берутся из .env (см. .env.example). +# +# docker compose up --build +# +name: dispelevilbot + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-dispel_evil} + POSTGRES_USER: ${DB_USER:-de_admin} + POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"] + interval: 10s + timeout: 5s + retries: 10 + # ports: + # - "5432:5432" # раскомментировать для доступа к БД с хоста + + # Накат миграций Flyway перед стартом бота (идемпотентно). + migrate: + image: flyway/flyway:10 + command: ["migrate"] + environment: + FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil} + FLYWAY_USER: ${DB_USER:-de_admin} + FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env} + FLYWAY_LOCATIONS: filesystem:/flyway/sql + FLYWAY_BASELINE_ON_MIGRATE: "true" + FLYWAY_CONNECT_RETRIES: "10" + volumes: + - ./sql:/flyway/sql:ro + depends_on: + postgres: + condition: service_healthy + restart: "no" + + bot: + build: . + image: dispelevilbot-bot:local + restart: unless-stopped + env_file: + - .env + environment: + # Внутри сети compose БД доступна по имени сервиса. + DB_HOST: postgres + DB_PORT: "5432" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + pgdata: diff --git a/env/prod.env.example b/env/prod.env.example new file mode 100644 index 0000000..251aed1 --- /dev/null +++ b/env/prod.env.example @@ -0,0 +1,26 @@ +# Прод-контур. Скопируйте в env/prod.env и заполните реальными значениями. +# env/*.env в git не попадают. +# +# Используется и для интерполяции, и как env_file сервиса: +# docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d +ENV_FILE=env/prod.env + +APP_ENV=prod + +BOT_TOKEN= + +# Postgres (для контейнера бота host = имя сервиса compose) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=dispel_evil +DB_USER=de_admin +DB_PASSWORD= +DB_POOL_MIN=1 +DB_POOL_MAX=10 +DB_STATEMENT_TIMEOUT_MS=15000 + +PROD_CHAT_ID= +TEST_CHAT_ID= +ALERT_CHAT_ID= +ADMIN_IDS= +TIMEZONE=Europe/Moscow diff --git a/env/test.env.example b/env/test.env.example new file mode 100644 index 0000000..9866008 --- /dev/null +++ b/env/test.env.example @@ -0,0 +1,24 @@ +# Тестовый контур. Скопируйте в env/test.env и заполните. +# Отдельные токен бота, чаты и БД от прода. +# +# docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d +ENV_FILE=env/test.env + +APP_ENV=dev + +BOT_TOKEN= + +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=dispel_evil_test +DB_USER=de_admin +DB_PASSWORD= +DB_POOL_MIN=1 +DB_POOL_MAX=10 +DB_STATEMENT_TIMEOUT_MS=15000 + +PROD_CHAT_ID= +TEST_CHAT_ID= +ALERT_CHAT_ID= +ADMIN_IDS= +TIMEZONE=Europe/Moscow diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..d39721a --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,53 @@ +"""Тесты рендера шаблонов (без БД и сети).""" + +from __future__ import annotations + +import pytest + +from common.texts.render import ( + TemplateError, + format_tops, + format_user_list, + format_usernames, + render, + validate, +) + + +def test_render_substitutes_and_escapes(): + template = "Привет, {username}!" + result = render(template, {"username": "bad"}) + assert result == "Привет, <b>bad</b>!" + + +def test_render_empty_value(): + assert render("Ник: {nick}", {"nick": None}) == "Ник: " + + +def test_render_unknown_tag_raises(): + with pytest.raises(TemplateError): + render("Значение {unknown}", {}) + + +def test_render_keeps_literal_braces_untouched(): + # Одиночная фигурная скобка не считается тегом. + assert render("текст { не тег", {}) == "текст { не тег" + + +def test_validate_reports_unknown_tags(): + assert validate("Норм {username}, плохо {nope} {bad2}") == ["bad2", "nope"] + + +def test_format_usernames_escapes_and_joins(): + rows = [{"username": "a"}, {"username": None}, {"username": "b&c"}] + assert format_usernames(rows) == "@a @b&c" + + +def test_format_user_list_numbering(): + rows = [{"username": "u1", "nick": "Nick1"}, {"username": None, "nick": None}] + assert format_user_list(rows) == "1: @u1 - Nick1\n2: — - " + + +def test_format_tops(): + rows = [{"nick": "N1", "count": 3}, {"nick": None, "count": 1}] + assert format_tops(rows) == "1) N1 - 3\n2) — - 1" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..ba48e5d --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,10 @@ +"""Проверка версии проекта.""" + +from __future__ import annotations + +from common import __version__ + + +def test_version_is_set(): + assert __version__ + assert __version__ == "0.1"